// // ScenicQueueTTSManager.swift // suixinkan // import AVFoundation import Foundation /// 排队语音播报服务协议,隔离具体 TTS SDK。 protocol ScenicQueueTTSManaging: AnyObject { /// 自定义文案是否正在循环播报。 var customTextLooping: Bool { get } /// 是否支持离线语音合成。 var supportsOfflineTTS: Bool { get } /// 预热排队 TTS 引擎。 func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async /// 开始循环播报指定排号。 func startQueueCallLoop(queueNo: String) /// 停止叫号循环。 func stopQueueCallLoop() /// 单次播报普通文本。 @discardableResult func speakPlainTextOnce(_ text: String) async -> Bool /// 开始循环播报自定义文本。 @discardableResult func startCustomTextLoop(_ text: String) async -> Bool /// 停止自定义文本循环。 func stopCustomTextLoop() /// 中断拍摄倒计时等临时播报。 func interruptShootingAnnouncement() /// 估算播报耗时。 func estimateAnnouncementDurationMillis(_ text: String) -> Int64 } /// 主线程隔离的系统语音合成器,统一管理播放状态与异步 continuation。 @MainActor private final class SystemVoiceSynthesizer: NSObject, @preconcurrency AVSpeechSynthesizerDelegate { private let synthesizer = AVSpeechSynthesizer() private var currentContinuation: CheckedContinuation? private var currentUtterance: AVSpeechUtterance? nonisolated override init() { super.init() } /// 播放一段中文文本,并在完成或取消后结束等待。 func speak(_ text: String) async { synthesizer.delegate = self finishCurrentSpeech() if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } await withCheckedContinuation { continuation in let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86 utterance.volume = 1.0 currentContinuation = continuation currentUtterance = utterance synthesizer.speak(utterance) } } /// 停止当前播放并释放等待中的调用方。 func stop() { finishCurrentSpeech() if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { finishCurrentSpeech(matching: utterance) } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { finishCurrentSpeech(matching: utterance) } private func finishCurrentSpeech(matching utterance: AVSpeechUtterance? = nil) { if let utterance, utterance !== currentUtterance { return } currentUtterance = nil currentContinuation?.resume() currentContinuation = nil } } /// 阿里云 NLS TTS 管理器,优先使用 NUI SDK,失败时回退到系统中文语音。 /// /// 线程安全性:循环状态只在 `loopQueue` 读写,原生引擎状态只在 `nativeQueue` 读写, /// 系统语音合成由 `SystemVoiceSynthesizer` 的主 Actor 隔离保护。 final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, @unchecked Sendable { static let shared = AliyunNLSTTSManager() private enum LoopKind { case queueCall(String) case custom(String) } /// 当前已初始化的阿里云原生 TTS 引擎类型。 private enum NativeEngineKind { case online case offline } /// 离线 TTS 工作目录及默认发音人资源。 private struct OfflineResources { let workspace: String let voicePath: String } private let systemVoiceSynthesizer = SystemVoiceSynthesizer() 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 let workspacePathProvider: () -> String? private var activeLoop: LoopKind? private var nativeContinuation: CheckedContinuation? private var nativeReady = false private var nativeEngineKind: NativeEngineKind? private var nativeSampleRate: Double private var customTextLoopingStorage = false var customTextLooping: Bool { loopQueue.sync { customTextLoopingStorage } } var supportsOfflineTTS: Bool { bridge.sdkAvailable && offlineResources() != nil } init( appStore: AppStore = .shared, tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(), bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(), streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer(), workspacePathProvider: (() -> String?)? = nil ) { self.appStore = appStore self.tokenProvider = tokenProvider self.bridge = bridge self.streamingPlayer = streamingPlayer self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate super.init() self.bridge.delegate = self } func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { guard let api else { return } guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else { await setNativeReady(false) return } do { let sts = try await api.aliyunSTS(bucket: "vipsky") let token = try await tokenProvider.createToken(credentials: sts.credentials) let isReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials) await setNativeReady(isReady) } catch { await setNativeReady(false) } } func startQueueCallLoop(queueNo: String) { let text = Self.callAnnouncementText(queueNo: queueNo) loopQueue.async { [weak self] in guard let self else { return } self.activeLoop = .queueCall(queueNo) self.customTextLoopingStorage = false Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) } } } func stopQueueCallLoop() { loopQueue.async { [weak self] in guard let self else { return } if case .queueCall = self.activeLoop { self.activeLoop = nil } self.stopSpeaking() } } @discardableResult func speakPlainTextOnce(_ text: String) async -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } await speak(trimmed) return true } @discardableResult func startCustomTextLoop(_ text: String) async -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } loopQueue.async { [weak self] in guard let self else { return } self.activeLoop = .custom(trimmed) self.customTextLoopingStorage = true Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) } } return true } func stopCustomTextLoop() { loopQueue.async { [weak self] in guard let self else { return } if case .custom = self.activeLoop { self.activeLoop = nil } self.customTextLoopingStorage = false self.stopSpeaking() } } func interruptShootingAnnouncement() { stopSpeaking() } func estimateAnnouncementDurationMillis(_ text: String) -> Int64 { let count = max(1, text.count) return Int64(min(max(850 + count * 200, 900), 12_000)) } /// 排队号 TTS 格式化,降低字母数字连读概率。 static func formatQueueNoForTTS(_ queueNo: String) -> String { let trimmed = queueNo.trimmingCharacters(in: .whitespacesAndNewlines) let core = trimmed.hasSuffix("号") ? String(trimmed.dropLast()) : trimmed guard !core.isEmpty else { return trimmed.isEmpty ? "请" : trimmed } let pattern = #"^([A-Za-z]+)([0-9]+)$"# if let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: core, range: NSRange(core.startIndex..., in: core)), let lettersRange = Range(match.range(at: 1), in: core), let digitsRange = Range(match.range(at: 2), in: core) { return "\(core[lettersRange].uppercased()),\(core[digitsRange])号" } return "\(core)号" } /// 叫号播报文案。 static func callAnnouncementText(queueNo: String) -> String { "\(formatQueueNoForTTS(queueNo)),请到拍摄区域,准备拍摄。" } private func runLoop(text: String, expected: LoopKind) async { while isLoopStillActive(expected) { await speak(text) guard isLoopStillActive(expected) else { break } try? await Task.sleep(nanoseconds: 1_000_000_000) } if case .custom = expected { loopQueue.async { [weak self] in self?.customTextLoopingStorage = false } } } private func isLoopStillActive(_ expected: LoopKind) -> Bool { loopQueue.sync { switch (activeLoop, expected) { case (.queueCall(let lhs), .queueCall(let rhs)): lhs == rhs case (.custom(let lhs), .custom(let rhs)): lhs == rhs default: false } } } 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.nativeSampleRate) 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 setNativeReady(_ isReady: Bool) async { await withCheckedContinuation { continuation in nativeQueue.async { [weak self] in self?.nativeReady = isReady continuation.resume() } } } private func speakWithSystemVoice(_ text: String) async { await systemVoiceSynthesizer.speak(text) } private func stopSpeaking() { nativeQueue.async { [weak self] in self?.cancelNativeSpeakLocked(resumeExisting: true) } Task { @MainActor [systemVoiceSynthesizer] in systemVoiceSynthesizer.stop() } } private func prepareNativeEngineIfAvailable( token: AliyunNLSToken, credentials: AliyunOSSCredentials ) async -> Bool { guard bridge.sdkAvailable else { return false } let wantsOffline = appStore.scenicQueue.useOfflineTTS let offlineResources = wantsOffline ? offlineResources() : nil guard !wantsOffline || offlineResources != nil else { return false } guard let ticket = wantsOffline ? nativeOfflineTicket(token: token.id, credentials: credentials, resources: offlineResources!) : 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() self.nativeEngineKind = nil let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true) guard code == 0 else { continuation.resume(returning: false) return } let configured: Bool if let offlineResources { configured = self.configureOfflineEngine(resources: offlineResources) } else { self.nativeEngineKind = .online self.nativeSampleRate = Self.onlineSampleRate self.applyNativeOnlineQueueProsodyParams() configured = true } guard configured else { _ = self.bridge.releaseEngine() self.nativeEngineKind = nil continuation.resume(returning: false) return } continuation.resume(returning: true) } } } private func nativeOnlineTicket(token: String) -> String? { guard let workspace = workspacePathProvider() 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 nativeOfflineTicket( token: String, credentials: AliyunOSSCredentials, resources: OfflineResources ) -> String? { let ticket: [String: String] = [ "app_key": Self.offlineAppKey, "ak_id": credentials.accessKeyId, "ak_secret": credentials.accessKeySecret, "sts_token": credentials.securityToken, "token": token, "sdk_code": Self.offlineSDKCode, "save_wav": "false", "debug_path": nativeDebugPath(), "workspace": resources.workspace, "log_track_level": "5", "mode_type": "0", "device_id": Self.deviceId(), ] guard let data = try? JSONSerialization.data(withJSONObject: ticket) else { return nil } return String(data: data, encoding: .utf8) } private static func bundledWorkspacePath() -> 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 offlineResources() -> OfflineResources? { guard let workspace = workspacePathProvider() else { return nil } let ttsPath = (workspace as NSString).appendingPathComponent("tts") let languagePath = (ttsPath as NSString).appendingPathComponent("languagedata_embedded.bin") let parameterPath = (ttsPath as NSString).appendingPathComponent("parameter.cfg") let voicePath = (ttsPath as NSString).appendingPathComponent("voices/\(Self.offlineVoiceName)") let fileManager = FileManager.default guard fileManager.fileExists(atPath: languagePath), fileManager.fileExists(atPath: parameterPath), fileManager.fileExists(atPath: voicePath) else { return nil } return OfflineResources(workspace: workspace, voicePath: voicePath) } 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) { switch nativeEngineKind { case .online: _ = bridge.setParam("mode_type", value: "2") _ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0") applyNativeOnlineQueueProsodyParams() case .offline: applyNativeOfflineQueueProsodyParams() case nil: break } } private func applyNativeOnlineQueueProsodyParams() { _ = bridge.setParam("speech_rate", value: Self.onlineSpeechRate) _ = bridge.setParam("volume", value: Self.playbackVolume) } private func configureOfflineEngine(resources: OfflineResources) -> Bool { guard bridge.setParam("extend_font_name", value: resources.voicePath) == 0 else { return false } guard bridge.setParam("encode_type", value: "pcm") == 0 else { return false } guard bridge.setParam("sample_rate", value: Self.offlineSampleRateText) == 0 else { return false } nativeEngineKind = .offline nativeSampleRate = Double(bridge.paramValue(for: "model_sample_rate") ?? "") .flatMap { $0 > 0 ? $0 : nil } ?? Self.offlineSampleRate applyNativeOfflineQueueProsodyParams() return true } private func applyNativeOfflineQueueProsodyParams() { _ = bridge.setParam("speed_level", value: Self.offlineSpeedLevel) _ = 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 offlineAppKey = "gfWimmOuTku1AbIV" private static let offlineSDKCode = "software_nls_tts_offline_standard" private static let offlineVoiceName = "aijia" private static let nuiTaskId = "1" private static let onlineSampleRate = 16_000.0 private static let offlineSampleRate = 24_000.0 private static let offlineSampleRateText = "24000" private static let onlineSpeechRate = "-120" private static let offlineSpeedLevel = "1.0" 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 } }