完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -109,8 +109,8 @@ final class HomeAPI {
|
||||
}
|
||||
|
||||
/// 提交角色权限申请。
|
||||
func submitRoleApply(scenicIds: [Int], roleId: Int) async throws -> RoleApplySubmitResult {
|
||||
try await client.send(
|
||||
func submitRoleApply(scenicIds: [Int], roleId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/role-apply/submit",
|
||||
|
||||
@ -5,6 +5,35 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页进入权限申请流程时的目标页面。
|
||||
enum PermissionApplyDestination: Equatable {
|
||||
case apply
|
||||
case status(applyCode: String)
|
||||
}
|
||||
|
||||
/// 权限申请页待确认删除的对象。
|
||||
enum PermissionDeletionTarget: Equatable {
|
||||
case role(name: String)
|
||||
case scenic(id: Int, name: String)
|
||||
|
||||
/// Android 删除弹窗标题。
|
||||
var dialogTitle: String {
|
||||
switch self {
|
||||
case .role: "是否删除该角色"
|
||||
case .scenic: "是否删除该景区"
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 删除弹窗正文。
|
||||
var dialogMessage: String {
|
||||
switch self {
|
||||
case .role(let name): "您确定要删除\(name)角色吗?"
|
||||
case .scenic(_, let name): "您确定要删除\(name)吗?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 待审核的角色权限申请响应。
|
||||
struct RoleApplyPendingResponse: Decodable, Equatable {
|
||||
let code: String
|
||||
let roleId: Int
|
||||
@ -45,11 +74,13 @@ struct RoleApplyPendingResponse: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限申请中的景区摘要。
|
||||
struct RoleApplyScenicItem: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// 全部可申请景区响应。
|
||||
struct ScenicListAllResponse: Decodable, Equatable {
|
||||
let total: Int
|
||||
let list: [ScenicListItem]
|
||||
@ -60,11 +91,13 @@ struct ScenicListAllResponse: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 可申请景区条目。
|
||||
struct ScenicListItem: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// 权限申请提交参数。
|
||||
struct RoleApplySubmitRequest: Encodable {
|
||||
let scenicId: [Int]
|
||||
let roleId: Int
|
||||
@ -75,6 +108,7 @@ struct RoleApplySubmitRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限申请角色选项。
|
||||
struct RoleOption: Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
@ -82,26 +116,10 @@ struct RoleOption: Equatable, Hashable {
|
||||
var isSelected: Bool
|
||||
}
|
||||
|
||||
/// 权限申请景区选项。
|
||||
struct ScenicOption: Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
var isSelected: Bool
|
||||
var isDisabled: Bool
|
||||
}
|
||||
|
||||
struct RoleApplySubmitResult: Decodable, Equatable {
|
||||
let code: String
|
||||
|
||||
init(code: String = "") {
|
||||
self.code = code
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
code = try container.decodeIfPresent(String.self, forKey: .code) ?? ""
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case code
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,16 +23,16 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"),
|
||||
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
||||
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "rectangle.stack.badge.plus"),
|
||||
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "play.rectangle.on.rectangle.fill"),
|
||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "circle.grid.2x2.fill"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "circle.grid.2x2.fill"),
|
||||
HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "mappin.circle.fill"),
|
||||
HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "shield.fill"),
|
||||
HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "shield.lefthalf.filled"),
|
||||
HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "envelope.open.fill"),
|
||||
HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "envelope.open.fill"),
|
||||
HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "list.bullet.rectangle.fill"),
|
||||
HomeMenuItem(uri: "store", title: "店铺管理", iconName: "storefront.fill"),
|
||||
HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2.fill"),
|
||||
HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "paperplane.fill"),
|
||||
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "paperplane.fill"),
|
||||
HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "paperplane.fill"),
|
||||
|
||||
@ -78,6 +78,21 @@ final class HomeViewModel {
|
||||
needsPermissionReload = true
|
||||
}
|
||||
|
||||
/// 查询待审核记录并决定进入申请页或审核状态页。
|
||||
func resolvePermissionApplyDestination(api: HomeAPI) async -> PermissionApplyDestination {
|
||||
do {
|
||||
let pendingList = try await api.roleApplyPending()
|
||||
guard let first = pendingList.first,
|
||||
first.status == PermissionApplyPendingStatus.reviewing
|
||||
|| first.status == PermissionApplyPendingStatus.rejected else {
|
||||
return .apply
|
||||
}
|
||||
return .status(applyCode: first.code)
|
||||
} catch {
|
||||
return .apply
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需重新拉取权限。
|
||||
func reloadIfNeeded(api: HomeAPI) async {
|
||||
locationStateStore.restoreStateIfNeeded()
|
||||
|
||||
@ -11,6 +11,9 @@ final class PermissionApplyStatusViewModel {
|
||||
private(set) var pendingData: RoleApplyPendingResponse?
|
||||
private(set) var isLoading = false
|
||||
private(set) var rolePermissionList: [RolePermissionResponse] = []
|
||||
private(set) var showScenicListDialog = false
|
||||
private(set) var dialogRoleName = ""
|
||||
private(set) var dialogScenicList: [ScenicInfo] = []
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)?
|
||||
@ -29,7 +32,11 @@ final class PermissionApplyStatusViewModel {
|
||||
notifyStateChange()
|
||||
do {
|
||||
let pendingList = try await api.roleApplyPending()
|
||||
pendingData = pendingList.first { $0.code == applyCode }
|
||||
pendingData = pendingList.first {
|
||||
$0.code == applyCode
|
||||
&& ($0.status == PermissionApplyPendingStatus.reviewing
|
||||
|| $0.status == PermissionApplyPendingStatus.rejected)
|
||||
}
|
||||
} catch {
|
||||
pendingData = nil
|
||||
}
|
||||
@ -42,6 +49,22 @@ final class PermissionApplyStatusViewModel {
|
||||
onNavigateToEdit?(pendingData)
|
||||
}
|
||||
|
||||
/// 展示某个已有角色的全部景区。
|
||||
func showScenicList(roleName: String, scenics: [ScenicInfo]) {
|
||||
dialogRoleName = roleName
|
||||
dialogScenicList = scenics
|
||||
showScenicListDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 关闭已有景区列表弹窗。
|
||||
func hideScenicList() {
|
||||
dialogRoleName = ""
|
||||
dialogScenicList = []
|
||||
showScenicListDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
@ -18,10 +18,16 @@ final class PermissionApplyViewModel {
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var showRolePicker = false
|
||||
private(set) var showScenicPicker = false
|
||||
private(set) var rolePermissionList: [RolePermissionResponse] = []
|
||||
private(set) var scenicSearchQuery = ""
|
||||
private(set) var pendingDeletion: PermissionDeletionTarget?
|
||||
private(set) var showScenicListDialog = false
|
||||
private(set) var dialogRoleName = ""
|
||||
private(set) var dialogScenicList: [ScenicInfo] = []
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitSuccess: ((String) -> Void)?
|
||||
var onSubmitSuccess: (() -> Void)?
|
||||
|
||||
private let appStore: AppStore
|
||||
|
||||
@ -30,6 +36,18 @@ final class PermissionApplyViewModel {
|
||||
loadRolesFromLocal()
|
||||
}
|
||||
|
||||
/// 当前表单是否满足 Android 的提交条件。
|
||||
var canSubmit: Bool {
|
||||
selectedRoleId != nil && !selectedScenicIds.isEmpty && !isSubmitting
|
||||
}
|
||||
|
||||
/// 按当前搜索词过滤景区,保持接口原始顺序。
|
||||
var filteredScenics: [ScenicOption] {
|
||||
let query = scenicSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return availableScenics }
|
||||
return availableScenics.filter { $0.name.localizedCaseInsensitiveContains(query) }
|
||||
}
|
||||
|
||||
/// 从待审核数据预填表单(status=3 去编辑)。
|
||||
func initWithPendingData(_ pending: RoleApplyPendingResponse) {
|
||||
selectedRoleId = pending.roleId
|
||||
@ -45,7 +63,7 @@ final class PermissionApplyViewModel {
|
||||
}
|
||||
|
||||
func loadRolesFromLocal() {
|
||||
let rolePermissionList = appStore.permissions.rolePermissionList()
|
||||
rolePermissionList = appStore.permissions.rolePermissionList()
|
||||
var seen = Set<Int>()
|
||||
availableRoles = rolePermissionList.compactMap { item in
|
||||
guard !seen.contains(item.role.id) else { return nil }
|
||||
@ -102,6 +120,7 @@ final class PermissionApplyViewModel {
|
||||
func loadAvailableScenics(api: HomeAPI?) async {
|
||||
guard let roleId = selectedRoleId else { return }
|
||||
guard let api else { return }
|
||||
guard !isLoadingScenics else { return }
|
||||
isLoadingScenics = true
|
||||
notifyStateChange()
|
||||
do {
|
||||
@ -143,7 +162,37 @@ final class PermissionApplyViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新景区下拉菜单的搜索词。
|
||||
func updateScenicSearchQuery(_ query: String) {
|
||||
scenicSearchQuery = query
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func removeSelectedScenic(id: Int, name: String) {
|
||||
pendingDeletion = .scenic(id: id, name: name)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 确认删除当前待删除的角色或景区。
|
||||
func confirmDeletion() {
|
||||
guard let pendingDeletion else { return }
|
||||
switch pendingDeletion {
|
||||
case .role:
|
||||
clearRoleSelectionImmediately()
|
||||
case .scenic(let id, let name):
|
||||
removeSelectedScenicImmediately(id: id, name: name)
|
||||
}
|
||||
self.pendingDeletion = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 取消删除确认。
|
||||
func cancelDeletion() {
|
||||
pendingDeletion = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func removeSelectedScenicImmediately(id: Int, name: String) {
|
||||
selectedScenicIds.removeAll { $0 == id }
|
||||
selectedScenicNames.removeAll { $0 == name }
|
||||
availableScenics = availableScenics.map { item in
|
||||
@ -153,7 +202,6 @@ final class PermissionApplyViewModel {
|
||||
}
|
||||
return copy
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 已有角色下已开通的景区列表。
|
||||
@ -164,6 +212,12 @@ final class PermissionApplyViewModel {
|
||||
}
|
||||
|
||||
func clearRoleSelection() {
|
||||
guard !selectedRoleName.isEmpty else { return }
|
||||
pendingDeletion = .role(name: selectedRoleName)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func clearRoleSelectionImmediately() {
|
||||
selectedRoleId = nil
|
||||
selectedRoleName = ""
|
||||
selectedScenicIds = []
|
||||
@ -174,33 +228,42 @@ final class PermissionApplyViewModel {
|
||||
copy.isSelected = false
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
/// 展示已有角色下的全部景区。
|
||||
func showScenicList(roleName: String, scenics: [ScenicInfo]) {
|
||||
dialogRoleName = roleName
|
||||
dialogScenicList = scenics
|
||||
showScenicListDialog = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 关闭已有景区列表弹窗。
|
||||
func hideScenicList() {
|
||||
dialogRoleName = ""
|
||||
dialogScenicList = []
|
||||
showScenicListDialog = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func submit(api: HomeAPI) async {
|
||||
guard let roleId = selectedRoleId else {
|
||||
onShowMessage?("请选择角色")
|
||||
return
|
||||
}
|
||||
let newScenicIds = selectedScenicIds.filter { id in
|
||||
!existingScenicIds(for: roleId).contains(id)
|
||||
}
|
||||
guard !newScenicIds.isEmpty else {
|
||||
onShowMessage?("请至少选择一个景区")
|
||||
guard canSubmit, let roleId = selectedRoleId else {
|
||||
onShowMessage?("请选择景区和角色")
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
notifyStateChange()
|
||||
do {
|
||||
let result = try await api.submitRoleApply(scenicIds: newScenicIds, roleId: roleId)
|
||||
onShowMessage?("提交成功")
|
||||
onSubmitSuccess?(result.code)
|
||||
try await api.submitRoleApply(scenicIds: selectedScenicIds, roleId: roleId)
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
onSubmitSuccess?()
|
||||
} catch {
|
||||
onShowMessage?("提交失败,请稍后重试")
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func existingScenicIds(for roleId: Int) -> Set<Int> {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user