集成阿里云 NUI 语音合成 SDK
This commit is contained in:
@ -0,0 +1,135 @@
|
||||
//
|
||||
// AliyunNLSTokenProvider.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// 阿里云 NLS token 实体,表示语音服务访问凭证及过期时间。
|
||||
struct AliyunNLSToken: Equatable {
|
||||
let id: String
|
||||
let expireTime: Int64?
|
||||
}
|
||||
|
||||
/// 阿里云 NLS token 获取协议,便于 TTS 管理器测试替换。
|
||||
protocol AliyunNLSTokenProviding {
|
||||
/// 使用临时 STS 凭证创建 NLS token。
|
||||
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken
|
||||
}
|
||||
|
||||
/// 阿里云 NLS token Provider,按 OpenAPI POP 签名请求 CreateToken。
|
||||
final class AliyunNLSTokenProvider: AliyunNLSTokenProviding {
|
||||
typealias DataLoader = (URLRequest) async throws -> Data
|
||||
|
||||
private let endpoint: URL
|
||||
private let dateProvider: () -> Date
|
||||
private let nonceProvider: () -> String
|
||||
private let dataLoader: DataLoader
|
||||
|
||||
init(
|
||||
endpoint: URL = URL(string: "https://nls-meta.cn-shanghai.aliyuncs.com/")!,
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
nonceProvider: @escaping () -> String = { UUID().uuidString },
|
||||
dataLoader: @escaping DataLoader = { request in
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
throw AliyunNLSTokenError.httpStatus(http.statusCode)
|
||||
}
|
||||
return data
|
||||
}
|
||||
) {
|
||||
self.endpoint = endpoint
|
||||
self.dateProvider = dateProvider
|
||||
self.nonceProvider = nonceProvider
|
||||
self.dataLoader = dataLoader
|
||||
}
|
||||
|
||||
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken {
|
||||
var params = signedBaseParams(credentials: credentials)
|
||||
params["Signature"] = Self.signature(params: params, accessKeySecret: credentials.accessKeySecret)
|
||||
let query = Self.canonicalQuery(params)
|
||||
guard let url = URL(string: endpoint.absoluteString + "?" + query) else {
|
||||
throw AliyunNLSTokenError.invalidURL
|
||||
}
|
||||
let request = URLRequest(url: url)
|
||||
let data = try await dataLoader(request)
|
||||
let response = try JSONDecoder().decode(AliyunNLSCreateTokenResponse.self, from: data)
|
||||
guard let id = response.token?.id?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty else {
|
||||
throw AliyunNLSTokenError.missingToken
|
||||
}
|
||||
return AliyunNLSToken(id: id, expireTime: response.token?.expireTime)
|
||||
}
|
||||
|
||||
/// 构造 CreateToken 的未签名公共参数。
|
||||
func signedBaseParams(credentials: AliyunOSSCredentials) -> [String: String] {
|
||||
[
|
||||
"AccessKeyId": credentials.accessKeyId,
|
||||
"Action": "CreateToken",
|
||||
"Format": "JSON",
|
||||
"RegionId": "cn-shanghai",
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": nonceProvider(),
|
||||
"SignatureVersion": "1.0",
|
||||
"Timestamp": Self.timestampFormatter.string(from: dateProvider()),
|
||||
"Version": "2019-02-28",
|
||||
"SecurityToken": credentials.securityToken,
|
||||
]
|
||||
}
|
||||
|
||||
/// 生成阿里云 POP HMAC-SHA1 签名。
|
||||
static func signature(params: [String: String], accessKeySecret: String) -> String {
|
||||
let canonical = canonicalQuery(params)
|
||||
let stringToSign = "GET&%2F&\(percentEncode(canonical))"
|
||||
let key = SymmetricKey(data: Data("\(accessKeySecret)&".utf8))
|
||||
let mac = HMAC<Insecure.SHA1>.authenticationCode(for: Data(stringToSign.utf8), using: key)
|
||||
return Data(mac).base64EncodedString()
|
||||
}
|
||||
|
||||
/// 生成按参数名排序后的 OpenAPI 查询串。
|
||||
static func canonicalQuery(_ params: [String: String]) -> String {
|
||||
params
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { "\(percentEncode($0.key))=\(percentEncode($0.value))" }
|
||||
.joined(separator: "&")
|
||||
}
|
||||
|
||||
/// 阿里云 POP 签名要求的 RFC3986 编码。
|
||||
static func percentEncode(_ value: String) -> String {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-_.~")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
}
|
||||
|
||||
private static let timestampFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 阿里云 NLS token 获取错误。
|
||||
enum AliyunNLSTokenError: Error, Equatable {
|
||||
case invalidURL
|
||||
case httpStatus(Int)
|
||||
case missingToken
|
||||
}
|
||||
|
||||
private struct AliyunNLSCreateTokenResponse: Decodable {
|
||||
let token: TokenPayload?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token = "Token"
|
||||
}
|
||||
|
||||
struct TokenPayload: Decodable {
|
||||
let id: String?
|
||||
let expireTime: Int64?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id = "Id"
|
||||
case expireTime = "ExpireTime"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.h
Normal file
59
suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.h
Normal file
@ -0,0 +1,59 @@
|
||||
//
|
||||
// AliyunNuiTTSBridge.h
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 阿里云 NUI TTS 事件代理,将 Objective-C SDK 回调转成稳定的 Swift 边界。
|
||||
@protocol AliyunNuiTTSBridgeDelegate;
|
||||
|
||||
/// 阿里云 NUI TTS Objective-C 桥接器,隔离 SDK 头文件和模拟器不可用逻辑。
|
||||
@interface AliyunNuiTTSBridge : NSObject
|
||||
|
||||
/// SDK 回调代理。
|
||||
@property (nonatomic, weak, nullable) id<AliyunNuiTTSBridgeDelegate> delegate;
|
||||
|
||||
/// 当前平台是否可直接加载 NUI TTS SDK。
|
||||
@property (nonatomic, readonly) BOOL sdkAvailable;
|
||||
|
||||
/// 初始化 NUI TTS 引擎。
|
||||
- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog;
|
||||
|
||||
/// 设置合成参数。
|
||||
- (NSInteger)setParam:(NSString *)param value:(NSString *)value;
|
||||
|
||||
/// 开始合成指定文本。
|
||||
- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text;
|
||||
|
||||
/// 取消指定任务,taskId 为空时取消全部任务。
|
||||
- (NSInteger)cancelWithTaskId:(nullable NSString *)taskId;
|
||||
|
||||
/// 释放 SDK 资源。
|
||||
- (NSInteger)releaseEngine;
|
||||
|
||||
/// 获取 SDK 参数值。
|
||||
- (nullable NSString *)paramValueForKey:(NSString *)key;
|
||||
|
||||
@end
|
||||
|
||||
/// 阿里云 NUI TTS 事件代理。
|
||||
@protocol AliyunNuiTTSBridgeDelegate <NSObject>
|
||||
|
||||
/// 收到 TTS 状态事件。
|
||||
- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge
|
||||
didReceiveEvent:(NSInteger)event
|
||||
taskId:(nullable NSString *)taskId
|
||||
code:(NSInteger)code;
|
||||
|
||||
/// 收到 TTS 音频数据。
|
||||
- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge
|
||||
didReceiveAudioData:(NSData *)data
|
||||
taskId:(nullable NSString *)taskId
|
||||
info:(nullable NSString *)info;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
138
suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.m
Normal file
138
suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.m
Normal file
@ -0,0 +1,138 @@
|
||||
//
|
||||
// AliyunNuiTTSBridge.m
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
#import "AliyunNuiTTSBridge.h"
|
||||
#import <TargetConditionals.h>
|
||||
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<nuisdk/NeoNuiTts.h>)
|
||||
#import <nuisdk/NeoNuiTts.h>
|
||||
#define ALIYUN_NUI_TTS_AVAILABLE 1
|
||||
#else
|
||||
#define ALIYUN_NUI_TTS_AVAILABLE 0
|
||||
#endif
|
||||
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
@interface AliyunNuiTTSBridge () <NeoNuiTtsDelegate>
|
||||
@property (nonatomic, strong, nullable) NeoNuiTts *nui;
|
||||
@end
|
||||
#else
|
||||
@interface AliyunNuiTTSBridge ()
|
||||
@end
|
||||
#endif
|
||||
|
||||
@implementation AliyunNuiTTSBridge
|
||||
|
||||
- (BOOL)sdkAvailable {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
return YES;
|
||||
#else
|
||||
return NO;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui != nil) {
|
||||
[self.nui nui_tts_release];
|
||||
self.nui.delegate = nil;
|
||||
self.nui = nil;
|
||||
}
|
||||
NeoNuiTts *nui = [NeoNuiTts get_instance];
|
||||
nui.delegate = self;
|
||||
NSInteger code = [nui nui_tts_initialize:[ticket UTF8String]
|
||||
logLevel:(NuiSdkLogLevel)logLevel
|
||||
saveLog:saveLog];
|
||||
if (code == 0) {
|
||||
self.nui = nui;
|
||||
} else {
|
||||
nui.delegate = nil;
|
||||
[nui nui_tts_release];
|
||||
}
|
||||
return code;
|
||||
#else
|
||||
(void)ticket;
|
||||
(void)logLevel;
|
||||
(void)saveLog;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSInteger)setParam:(NSString *)param value:(NSString *)value {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui == nil) { return -1; }
|
||||
return [self.nui nui_tts_set_param:[param UTF8String] value:[value UTF8String]];
|
||||
#else
|
||||
(void)param;
|
||||
(void)value;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui == nil) { return -1; }
|
||||
return [self.nui nui_tts_play:[priority UTF8String] taskId:[taskId UTF8String] text:[text UTF8String]];
|
||||
#else
|
||||
(void)priority;
|
||||
(void)taskId;
|
||||
(void)text;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSInteger)cancelWithTaskId:(NSString *)taskId {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui == nil) { return -1; }
|
||||
const char *task = taskId.length > 0 ? [taskId UTF8String] : NULL;
|
||||
return [self.nui nui_tts_cancel:task];
|
||||
#else
|
||||
(void)taskId;
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSInteger)releaseEngine {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui == nil) { return 0; }
|
||||
NSInteger code = [self.nui nui_tts_release];
|
||||
self.nui.delegate = nil;
|
||||
self.nui = nil;
|
||||
return code;
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (NSString *)paramValueForKey:(NSString *)key {
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
if (self.nui == nil) { return nil; }
|
||||
const char *value = [self.nui nui_tts_get_param:[key UTF8String]];
|
||||
if (value == NULL) { return nil; }
|
||||
return [NSString stringWithUTF8String:value];
|
||||
#else
|
||||
(void)key;
|
||||
return nil;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if ALIYUN_NUI_TTS_AVAILABLE
|
||||
- (void)onNuiTtsEventCallback:(NuiSdkTtsEvent)event taskId:(char *)taskid code:(int)code {
|
||||
NSString *taskId = taskid != NULL ? [NSString stringWithUTF8String:taskid] : nil;
|
||||
[self.delegate aliyunNuiTTSBridge:self didReceiveEvent:event taskId:taskId code:code];
|
||||
}
|
||||
|
||||
- (void)onNuiTtsUserdataCallback:(char *)info infoLen:(int)info_len buffer:(char *)buffer len:(int)len taskId:(char *)task_id {
|
||||
if (buffer == NULL || len <= 0) { return; }
|
||||
NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)len];
|
||||
NSString *taskId = task_id != NULL ? [NSString stringWithUTF8String:task_id] : nil;
|
||||
NSString *infoText = nil;
|
||||
if (info != NULL && info_len > 0) {
|
||||
infoText = [[NSString alloc] initWithBytes:info length:(NSUInteger)info_len encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
[self.delegate aliyunNuiTTSBridge:self didReceiveAudioData:data taskId:taskId info:infoText];
|
||||
}
|
||||
#endif
|
||||
|
||||
@end
|
||||
@ -0,0 +1,130 @@
|
||||
//
|
||||
// AliyunPCMStreamingPlayer.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// TTS PCM 流式播放器协议,便于单元测试替换音频输出。
|
||||
protocol AliyunPCMStreamingPlaying: AnyObject {
|
||||
/// 开始播放指定采样率的 16-bit mono PCM。
|
||||
func start(sampleRate: Double)
|
||||
|
||||
/// 写入一段 16-bit little-endian mono PCM 数据。
|
||||
func write(_ data: Data)
|
||||
|
||||
/// 通知播放器没有更多数据,已排空后回调。
|
||||
func drain(_ completion: @escaping () -> Void)
|
||||
|
||||
/// 立即停止并清空播放队列。
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// 基于 AVAudioEngine 的 16-bit PCM 流式播放器。
|
||||
final class AliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
|
||||
private let stateQueue = DispatchQueue(label: "com.zhifly.suixinkan.aliyunNLS.pcmPlayer")
|
||||
private let engine = AVAudioEngine()
|
||||
private let playerNode = AVAudioPlayerNode()
|
||||
private var format: AVAudioFormat?
|
||||
private var scheduledBufferCount = 0
|
||||
private var draining = false
|
||||
private var drainCompletion: (() -> Void)?
|
||||
private var started = false
|
||||
|
||||
func start(sampleRate: Double) {
|
||||
stateQueue.sync {
|
||||
configureSession()
|
||||
if engine.attachedNodes.contains(playerNode) == false {
|
||||
engine.attach(playerNode)
|
||||
}
|
||||
let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)
|
||||
format = audioFormat
|
||||
if let audioFormat {
|
||||
engine.disconnectNodeOutput(playerNode)
|
||||
engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat)
|
||||
}
|
||||
if engine.isRunning == false {
|
||||
try? engine.start()
|
||||
}
|
||||
if playerNode.isPlaying == false {
|
||||
playerNode.play()
|
||||
}
|
||||
started = true
|
||||
draining = false
|
||||
drainCompletion = nil
|
||||
}
|
||||
}
|
||||
|
||||
func write(_ data: Data) {
|
||||
guard data.count >= 2 else { return }
|
||||
stateQueue.async {
|
||||
guard self.started, let format = self.format else { return }
|
||||
let buffer = Self.makePCMBuffer(from: data, format: format)
|
||||
guard let buffer else { return }
|
||||
self.scheduledBufferCount += 1
|
||||
self.playerNode.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
|
||||
self?.stateQueue.async {
|
||||
guard let self else { return }
|
||||
self.scheduledBufferCount = max(0, self.scheduledBufferCount - 1)
|
||||
self.completeDrainIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drain(_ completion: @escaping () -> Void) {
|
||||
stateQueue.async {
|
||||
self.draining = true
|
||||
self.drainCompletion = completion
|
||||
self.completeDrainIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stateQueue.sync {
|
||||
playerNode.stop()
|
||||
engine.stop()
|
||||
scheduledBufferCount = 0
|
||||
draining = false
|
||||
drainCompletion = nil
|
||||
started = false
|
||||
}
|
||||
}
|
||||
|
||||
private func completeDrainIfNeeded() {
|
||||
guard draining, scheduledBufferCount == 0 else { return }
|
||||
let completion = drainCompletion
|
||||
drainCompletion = nil
|
||||
draining = false
|
||||
started = false
|
||||
playerNode.stop()
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func configureSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, options: [.allowBluetoothHFP, .mixWithOthers])
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
private static func makePCMBuffer(from data: Data, format: AVAudioFormat) -> AVAudioPCMBuffer? {
|
||||
let frameCount = data.count / MemoryLayout<Int16>.size
|
||||
guard frameCount > 0,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)),
|
||||
let channel = buffer.floatChannelData?[0] else {
|
||||
return nil
|
||||
}
|
||||
buffer.frameLength = AVAudioFrameCount(frameCount)
|
||||
data.withUnsafeBytes { raw in
|
||||
let bytes = raw.bindMemory(to: UInt8.self)
|
||||
for index in 0..<frameCount {
|
||||
let low = UInt16(bytes[index * 2])
|
||||
let high = UInt16(bytes[index * 2 + 1]) << 8
|
||||
let sample = Int16(bitPattern: high | low)
|
||||
channel[index] = Float(sample) / Float(Int16.max)
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,11 @@ protocol ScenicQueueTTSManaging: AnyObject {
|
||||
/// 自定义文案是否正在循环播报。
|
||||
var customTextLooping: Bool { get }
|
||||
|
||||
/// 是否支持离线语音合成。
|
||||
var supportsOfflineTTS: Bool { get }
|
||||
|
||||
/// 预热排队 TTS 引擎。
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async
|
||||
|
||||
/// 开始循环播报指定排号。
|
||||
func startQueueCallLoop(queueNo: String)
|
||||
@ -38,10 +41,7 @@ protocol ScenicQueueTTSManaging: AnyObject {
|
||||
func estimateAnnouncementDurationMillis(_ text: String) -> Int64
|
||||
}
|
||||
|
||||
/// 阿里云 NLS TTS 管理器。
|
||||
///
|
||||
/// 当前工程未随仓库提供阿里云 iOS NativeNui SDK,类中保留 STS/NLS 预热链路与 SDK 桥接边界;
|
||||
/// 在 SDK framework 加入后可在 `prepareNativeEngineIfAvailable` 内完成真实 NativeNui 初始化。
|
||||
/// 阿里云 NLS TTS 管理器,优先使用 NUI SDK,失败时回退到系统中文语音。
|
||||
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
|
||||
static let shared = AliyunNLSTTSManager()
|
||||
|
||||
@ -52,27 +52,50 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
|
||||
private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS")
|
||||
private let appStore: AppStore
|
||||
private let tokenProvider: AliyunNLSTokenProviding
|
||||
private let bridge: AliyunNuiTTSBridging
|
||||
private let streamingPlayer: AliyunPCMStreamingPlaying
|
||||
private var activeLoop: LoopKind?
|
||||
private var currentContinuation: CheckedContinuation<Void, Never>?
|
||||
private var nlsToken: String?
|
||||
private var nativeContinuation: CheckedContinuation<Bool, Never>?
|
||||
private var nlsToken: AliyunNLSToken?
|
||||
private var lastCredentials: AliyunOSSCredentials?
|
||||
private var nativeReady = false
|
||||
|
||||
private(set) var customTextLooping = false
|
||||
let supportsOfflineTTS = false
|
||||
|
||||
override init() {
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(),
|
||||
bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(),
|
||||
streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer()
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.tokenProvider = tokenProvider
|
||||
self.bridge = bridge
|
||||
self.streamingPlayer = streamingPlayer
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
self.bridge.delegate = self
|
||||
}
|
||||
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async {
|
||||
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
|
||||
guard let api else { return }
|
||||
guard !appStore.scenicQueueUseOfflineTTS || supportsOfflineTTS else {
|
||||
nativeReady = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
let sts = try await api.aliyunSTS(bucket: "vipsky")
|
||||
lastCredentials = sts.credentials
|
||||
nlsToken = nil
|
||||
prepareNativeEngineIfAvailable(credentials: sts.credentials)
|
||||
let token = try await tokenProvider.createToken(credentials: sts.credentials)
|
||||
nlsToken = token
|
||||
nativeReady = await prepareNativeEngineIfAvailable(token: token)
|
||||
} catch {
|
||||
// Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails.
|
||||
nativeReady = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,6 +205,35 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
}
|
||||
|
||||
private func speak(_ text: String) async {
|
||||
if await speakWithNativeEngine(text) {
|
||||
return
|
||||
}
|
||||
await speakWithSystemVoice(text)
|
||||
}
|
||||
|
||||
private func speakWithNativeEngine(_ text: String) async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
nativeQueue.async { [weak self] in
|
||||
guard let self, self.nativeReady, self.bridge.sdkAvailable else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
self.cancelNativeSpeakLocked(resumeExisting: true)
|
||||
self.nativeContinuation = continuation
|
||||
self.streamingPlayer.stop()
|
||||
self.streamingPlayer.start(sampleRate: Self.nuiSampleRate)
|
||||
self.applyNativePlaybackParams(text: text)
|
||||
let code = self.bridge.play(priority: "1", taskId: Self.nuiTaskId, text: text)
|
||||
if code != 0 {
|
||||
self.streamingPlayer.stop()
|
||||
self.nativeContinuation = nil
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func speakWithSystemVoice(_ text: String) async {
|
||||
await MainActor.run {
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
@ -200,6 +252,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
}
|
||||
|
||||
private func stopSpeaking() {
|
||||
nativeQueue.async { [weak self] in
|
||||
self?.cancelNativeSpeakLocked(resumeExisting: true)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if self.synthesizer.isSpeaking {
|
||||
self.synthesizer.stopSpeaking(at: .immediate)
|
||||
@ -221,8 +276,200 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
resumeCurrentContinuationIfNeeded()
|
||||
}
|
||||
|
||||
private func prepareNativeEngineIfAvailable(credentials: AliyunOSSCredentials) {
|
||||
_ = NSClassFromString("NativeNui")
|
||||
_ = credentials
|
||||
private func prepareNativeEngineIfAvailable(token: AliyunNLSToken) async -> Bool {
|
||||
guard bridge.sdkAvailable,
|
||||
let ticket = nativeOnlineTicket(token: token.id) else {
|
||||
return false
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
nativeQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
self.cancelNativeSpeakLocked(resumeExisting: true)
|
||||
_ = self.bridge.releaseEngine()
|
||||
let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true)
|
||||
guard code == 0 else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
self.applyNativeQueueProsodyParams()
|
||||
continuation.resume(returning: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func nativeOnlineTicket(token: String) -> String? {
|
||||
guard let workspace = nativeWorkspacePath() else { return nil }
|
||||
let ticket: [String: String] = [
|
||||
"app_key": Self.onlineAppKey,
|
||||
"token": token,
|
||||
"workspace": workspace,
|
||||
"debug_path": nativeDebugPath(),
|
||||
"url": "wss://nls-gateway.cn-shanghai.aliyuncs.com:443/ws/v1",
|
||||
"device_id": Self.deviceId(),
|
||||
"mode_type": "2",
|
||||
"tts_version": "0",
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: ticket) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func nativeWorkspacePath() -> String? {
|
||||
if let privateFrameworksPath = Bundle.main.privateFrameworksPath {
|
||||
let frameworkPath = (privateFrameworksPath as NSString).appendingPathComponent("nuisdk.framework")
|
||||
if let frameworkBundle = Bundle(path: frameworkPath),
|
||||
let path = frameworkBundle.path(forResource: "Resources", ofType: "bundle") {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return Bundle.main.path(forResource: "Resources", ofType: "bundle")
|
||||
}
|
||||
|
||||
private func nativeDebugPath() -> String {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
let url = (caches ?? URL(fileURLWithPath: NSTemporaryDirectory())).appendingPathComponent("aliyun_nls_tts_debug", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url.path
|
||||
}
|
||||
|
||||
private func applyNativePlaybackParams(text: String) {
|
||||
_ = bridge.setParam("mode_type", value: "2")
|
||||
_ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0")
|
||||
applyNativeQueueProsodyParams()
|
||||
}
|
||||
|
||||
private func applyNativeQueueProsodyParams() {
|
||||
_ = bridge.setParam("speech_rate", value: Self.onlineSpeechRate)
|
||||
_ = bridge.setParam("volume", value: Self.playbackVolume)
|
||||
}
|
||||
|
||||
private func cancelNativeSpeakLocked(resumeExisting: Bool) {
|
||||
_ = bridge.cancel(taskId: Self.nuiTaskId)
|
||||
streamingPlayer.stop()
|
||||
if resumeExisting {
|
||||
finishNativeSpeakLocked(success: false)
|
||||
} else {
|
||||
nativeContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNativeSpeakLocked(success: Bool) {
|
||||
let continuation = nativeContinuation
|
||||
nativeContinuation = nil
|
||||
continuation?.resume(returning: success)
|
||||
}
|
||||
|
||||
private static func deviceId() -> String {
|
||||
let key = "aliyun_nls_tts_device_id_v1"
|
||||
if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
let value = UUID().uuidString
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
return value
|
||||
}
|
||||
|
||||
private static let onlineAppKey = "3WX8cD04JCGkbRLi"
|
||||
private static let nuiTaskId = "1"
|
||||
private static let nuiSampleRate = 16_000.0
|
||||
private static let onlineSpeechRate = "-120"
|
||||
private static let playbackVolume = "2.0"
|
||||
}
|
||||
|
||||
/// 阿里云 NUI TTS Swift 桥接协议。
|
||||
protocol AliyunNuiTTSBridging: AnyObject {
|
||||
/// SDK 回调代理。
|
||||
var delegate: AliyunNuiTTSBridgeDelegate? { get set }
|
||||
|
||||
/// 当前平台是否可使用 SDK。
|
||||
var sdkAvailable: Bool { get }
|
||||
|
||||
/// 初始化引擎。
|
||||
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int
|
||||
|
||||
/// 设置参数。
|
||||
func setParam(_ param: String, value: String) -> Int
|
||||
|
||||
/// 开始播放。
|
||||
func play(priority: String, taskId: String, text: String) -> Int
|
||||
|
||||
/// 取消任务。
|
||||
func cancel(taskId: String?) -> Int
|
||||
|
||||
/// 释放引擎。
|
||||
func releaseEngine() -> Int
|
||||
|
||||
/// 读取 SDK 参数。
|
||||
func paramValue(for key: String) -> String?
|
||||
}
|
||||
|
||||
/// 阿里云 NUI TTS Objective-C 桥接器的 Swift 适配器。
|
||||
final class AliyunNuiTTSBridgeAdapter: AliyunNuiTTSBridging {
|
||||
private let bridge: AliyunNuiTTSBridge
|
||||
|
||||
var delegate: AliyunNuiTTSBridgeDelegate? {
|
||||
get { bridge.delegate }
|
||||
set { bridge.delegate = newValue }
|
||||
}
|
||||
|
||||
var sdkAvailable: Bool { bridge.sdkAvailable }
|
||||
|
||||
init(bridge: AliyunNuiTTSBridge = AliyunNuiTTSBridge()) {
|
||||
self.bridge = bridge
|
||||
}
|
||||
|
||||
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
|
||||
bridge.initialize(withTicket: ticket, logLevel: logLevel, saveLog: saveLog)
|
||||
}
|
||||
|
||||
func setParam(_ param: String, value: String) -> Int {
|
||||
bridge.setParam(param, value: value)
|
||||
}
|
||||
|
||||
func play(priority: String, taskId: String, text: String) -> Int {
|
||||
bridge.play(withPriority: priority, taskId: taskId, text: text)
|
||||
}
|
||||
|
||||
func cancel(taskId: String?) -> Int {
|
||||
bridge.cancel(withTaskId: taskId)
|
||||
}
|
||||
|
||||
func releaseEngine() -> Int {
|
||||
bridge.releaseEngine()
|
||||
}
|
||||
|
||||
func paramValue(for key: String) -> String? {
|
||||
bridge.paramValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
extension AliyunNLSTTSManager: AliyunNuiTTSBridgeDelegate {
|
||||
func aliyunNuiTTSBridge(_ bridge: AliyunNuiTTSBridge, didReceiveEvent event: Int, taskId: String?, code: Int) {
|
||||
nativeQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
switch event {
|
||||
case 1:
|
||||
self.streamingPlayer.drain { [weak self] in
|
||||
self?.nativeQueue.async {
|
||||
self?.finishNativeSpeakLocked(success: true)
|
||||
}
|
||||
}
|
||||
case 2, 5:
|
||||
self.streamingPlayer.stop()
|
||||
self.finishNativeSpeakLocked(success: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
_ = taskId
|
||||
_ = code
|
||||
}
|
||||
}
|
||||
|
||||
func aliyunNuiTTSBridge(_ bridge: AliyunNuiTTSBridge, didReceiveAudioData data: Data, taskId: String?, info: String?) {
|
||||
streamingPlayer.write(data)
|
||||
_ = taskId
|
||||
_ = info
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user