Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift
T
lujiuyinandCursor 5eef31b8da 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:51:23 +08:00

624 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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<Void, Never>?
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<Bool, Never>?
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
}
}