完善首页权限申请流程与队列播报体验。

对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 13:46:45 +08:00
parent c083f1d4b3
commit efb3925257
50 changed files with 2585 additions and 455 deletions

View File

@ -50,6 +50,18 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
case custom(String)
}
/// TTS
private enum NativeEngineKind {
case online
case offline
}
/// 线 TTS
private struct OfflineResources {
let workspace: String
let voicePath: 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")
@ -57,26 +69,35 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
private let tokenProvider: AliyunNLSTokenProviding
private let bridge: AliyunNuiTTSBridging
private let streamingPlayer: AliyunPCMStreamingPlaying
private let workspacePathProvider: () -> String?
private var activeLoop: LoopKind?
private var currentContinuation: CheckedContinuation<Void, Never>?
private var nativeContinuation: CheckedContinuation<Bool, Never>?
private var nlsToken: AliyunNLSToken?
private var lastCredentials: AliyunOSSCredentials?
private var nativeReady = false
private var nativeEngineKind: NativeEngineKind?
private var nativeSampleRate: Double
private(set) var customTextLooping = false
let supportsOfflineTTS = false
var supportsOfflineTTS: Bool {
bridge.sdkAvailable && offlineResources() != nil
}
init(
appStore: AppStore = .shared,
tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(),
bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(),
streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer()
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()
synthesizer.delegate = self
self.bridge.delegate = self
@ -93,7 +114,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
lastCredentials = sts.credentials
let token = try await tokenProvider.createToken(credentials: sts.credentials)
nlsToken = token
nativeReady = await prepareNativeEngineIfAvailable(token: token)
nativeReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
} catch {
nativeReady = false
}
@ -221,7 +242,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
self.cancelNativeSpeakLocked(resumeExisting: true)
self.nativeContinuation = continuation
self.streamingPlayer.stop()
self.streamingPlayer.start(sampleRate: Self.nuiSampleRate)
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 {
@ -276,11 +297,20 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
resumeCurrentContinuationIfNeeded()
}
private func prepareNativeEngineIfAvailable(token: AliyunNLSToken) async -> Bool {
guard bridge.sdkAvailable,
let ticket = nativeOnlineTicket(token: token.id) else {
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 {
@ -289,19 +319,34 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
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
}
self.applyNativeQueueProsodyParams()
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 = nativeWorkspacePath() else { return nil }
guard let workspace = workspacePathProvider() else { return nil }
let ticket: [String: String] = [
"app_key": Self.onlineAppKey,
"token": token,
@ -316,7 +361,30 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
return String(data: data, encoding: .utf8)
}
private func nativeWorkspacePath() -> String? {
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),
@ -327,6 +395,19 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
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)
@ -335,16 +416,39 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
private func applyNativePlaybackParams(text: String) {
_ = bridge.setParam("mode_type", value: "2")
_ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0")
applyNativeQueueProsodyParams()
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 applyNativeQueueProsodyParams() {
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()
@ -372,9 +476,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
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 nuiSampleRate = 16_000.0
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"
}