完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -41,8 +41,67 @@ protocol ScenicQueueTTSManaging: AnyObject {
|
||||
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,失败时回退到系统中文语音。
|
||||
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
|
||||
///
|
||||
/// 线程安全性:循环状态只在 `loopQueue` 读写,原生引擎状态只在 `nativeQueue` 读写,
|
||||
/// 系统语音合成由 `SystemVoiceSynthesizer` 的主 Actor 隔离保护。
|
||||
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, @unchecked Sendable {
|
||||
static let shared = AliyunNLSTTSManager()
|
||||
|
||||
private enum LoopKind {
|
||||
@ -62,7 +121,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
let voicePath: String
|
||||
}
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
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
|
||||
@ -71,15 +130,15 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
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 var customTextLoopingStorage = false
|
||||
|
||||
private(set) var customTextLooping = false
|
||||
var customTextLooping: Bool {
|
||||
loopQueue.sync { customTextLoopingStorage }
|
||||
}
|
||||
|
||||
var supportsOfflineTTS: Bool {
|
||||
bridge.sdkAvailable && offlineResources() != nil
|
||||
@ -99,24 +158,22 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
self.workspacePathProvider = workspacePathProvider ?? Self.bundledWorkspacePath
|
||||
nativeSampleRate = AliyunNLSTTSManager.onlineSampleRate
|
||||
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
|
||||
await setNativeReady(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, credentials: sts.credentials)
|
||||
let isReady = await prepareNativeEngineIfAvailable(token: token, credentials: sts.credentials)
|
||||
await setNativeReady(isReady)
|
||||
} catch {
|
||||
nativeReady = false
|
||||
await setNativeReady(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +182,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeLoop = .queueCall(queueNo)
|
||||
self.customTextLooping = false
|
||||
self.customTextLoopingStorage = false
|
||||
Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) }
|
||||
}
|
||||
}
|
||||
@ -155,7 +212,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
loopQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeLoop = .custom(trimmed)
|
||||
self.customTextLooping = true
|
||||
self.customTextLoopingStorage = true
|
||||
Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) }
|
||||
}
|
||||
return true
|
||||
@ -167,7 +224,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
if case .custom = self.activeLoop {
|
||||
self.activeLoop = nil
|
||||
}
|
||||
self.customTextLooping = false
|
||||
self.customTextLoopingStorage = false
|
||||
self.stopSpeaking()
|
||||
}
|
||||
}
|
||||
@ -208,7 +265,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
if case .custom = expected {
|
||||
customTextLooping = false
|
||||
loopQueue.async { [weak self] in
|
||||
self?.customTextLoopingStorage = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -254,49 +313,28 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
|
||||
}
|
||||
}
|
||||
|
||||
private func speakWithSystemVoice(_ text: String) async {
|
||||
await MainActor.run {
|
||||
if synthesizer.isSpeaking {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
}
|
||||
private func setNativeReady(_ isReady: Bool) async {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if self.synthesizer.isSpeaking {
|
||||
self.synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
self.resumeCurrentContinuationIfNeeded()
|
||||
Task { @MainActor [systemVoiceSynthesizer] in
|
||||
systemVoiceSynthesizer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
credentials: AliyunOSSCredentials
|
||||
|
||||
Reference in New Issue
Block a user