// // ScenicQueueTTSManager.swift // suixinkan // import AVFoundation import Foundation /// 排队语音播报服务协议,隔离具体 TTS SDK。 protocol ScenicQueueTTSManaging: AnyObject { /// 自定义文案是否正在循环播报。 var customTextLooping: Bool { get } /// 预热排队 TTS 引擎。 func prepareQueueTTSEngine(api: ScenicQueueAPI?) 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 管理器。 /// /// 当前工程未随仓库提供阿里云 iOS NativeNui SDK,类中保留 STS/NLS 预热链路与 SDK 桥接边界; /// 在 SDK framework 加入后可在 `prepareNativeEngineIfAvailable` 内完成真实 NativeNui 初始化。 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 var activeLoop: LoopKind? private var currentContinuation: CheckedContinuation? private var nlsToken: String? private var lastCredentials: AliyunOSSCredentials? private(set) var customTextLooping = false override init() { super.init() synthesizer.delegate = self } func prepareQueueTTSEngine(api: ScenicQueueAPI?) async { guard let api else { return } do { let sts = try await api.aliyunSTS(bucket: "vipsky") lastCredentials = sts.credentials nlsToken = nil prepareNativeEngineIfAvailable(credentials: sts.credentials) } catch { // Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails. } } 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 { 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() { 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(credentials: AliyunOSSCredentials) { _ = NSClassFromString("NativeNui") _ = credentials } }