// // 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 } /// 阿里云 NLS TTS 管理器,优先使用 NUI SDK,失败时回退到系统中文语音。 final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate { static let shared = AliyunNLSTTSManager() private enum LoopKind { case queueCall(String) case custom(String) } 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? private var nativeContinuation: CheckedContinuation? private var nlsToken: AliyunNLSToken? private var lastCredentials: AliyunOSSCredentials? private var nativeReady = false private(set) var customTextLooping = false let supportsOfflineTTS = false 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: ScenicQueueAPIProtocol?) async { guard let api else { return } guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else { nativeReady = false return } do { let sts = try await api.aliyunSTS(bucket: "vipsky") lastCredentials = sts.credentials let token = try await tokenProvider.createToken(credentials: sts.credentials) nlsToken = token nativeReady = await prepareNativeEngineIfAvailable(token: token) } catch { nativeReady = 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.customTextLooping = 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.customTextLooping = 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.customTextLooping = 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 { customTextLooping = 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.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) } } await withCheckedContinuation { continuation in currentContinuation = continuation let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86 utterance.volume = 1.0 DispatchQueue.main.async { self.synthesizer.speak(utterance) } } } private func stopSpeaking() { nativeQueue.async { [weak self] in self?.cancelNativeSpeakLocked(resumeExisting: true) } DispatchQueue.main.async { if self.synthesizer.isSpeaking { self.synthesizer.stopSpeaking(at: .immediate) } self.resumeCurrentContinuationIfNeeded() } } private func resumeCurrentContinuationIfNeeded() { currentContinuation?.resume() currentContinuation = nil } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { resumeCurrentContinuationIfNeeded() } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { resumeCurrentContinuationIfNeeded() } 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 } }