集成阿里云 NUI 语音合成 SDK

This commit is contained in:
2026-07-07 16:05:39 +08:00
parent 0aa8b14e1f
commit 9378748a4d
26 changed files with 2095 additions and 16 deletions
@@ -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
}
}