Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

476 lines
16 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
}
/// NLS TTS 使 NUI SDK退
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 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 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(set) var customTextLooping = false
let supportsOfflineTTS = false
init(
appStore: AppStore = .shared,
tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(),
bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(),
streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer()
) {
self.appStore = appStore
self.tokenProvider = tokenProvider
self.bridge = bridge
self.streamingPlayer = streamingPlayer
super.init()
synthesizer.delegate = self
self.bridge.delegate = self
}
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
guard let api else { return }
guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else {
nativeReady = false
return
}
do {
let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
let token = try await tokenProvider.createToken(credentials: sts.credentials)
nlsToken = token
nativeReady = await prepareNativeEngineIfAvailable(token: token)
} catch {
nativeReady = 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.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 {
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.nuiSampleRate)
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 speakWithSystemVoice(_ 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() {
nativeQueue.async { [weak self] in
self?.cancelNativeSpeakLocked(resumeExisting: true)
}
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(token: AliyunNLSToken) async -> Bool {
guard bridge.sdkAvailable,
let ticket = 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()
let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true)
guard code == 0 else {
continuation.resume(returning: false)
return
}
self.applyNativeQueueProsodyParams()
continuation.resume(returning: true)
}
}
}
private func nativeOnlineTicket(token: String) -> String? {
guard let workspace = nativeWorkspacePath() 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 nativeWorkspacePath() -> 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 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) {
_ = bridge.setParam("mode_type", value: "2")
_ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0")
applyNativeQueueProsodyParams()
}
private func applyNativeQueueProsodyParams() {
_ = bridge.setParam("speech_rate", value: Self.onlineSpeechRate)
_ = 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 nuiTaskId = "1"
private static let nuiSampleRate = 16_000.0
private static let onlineSpeechRate = "-120"
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
}
}