Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift
汉秋 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
}
}