Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift
2026-07-07 15:32:25 +08:00

229 lines
7.6 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 }
/// TTS
func prepareQueueTTSEngine(api: ScenicQueueAPI?) 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
///
/// iOS NativeNui SDK STS/NLS SDK
/// SDK framework `prepareNativeEngineIfAvailable` NativeNui
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 var activeLoop: LoopKind?
private var currentContinuation: CheckedContinuation<Void, Never>?
private var nlsToken: String?
private var lastCredentials: AliyunOSSCredentials?
private(set) var customTextLooping = false
override init() {
super.init()
synthesizer.delegate = self
}
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async {
guard let api else { return }
do {
let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
nlsToken = nil
prepareNativeEngineIfAvailable(credentials: sts.credentials)
} catch {
// Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails.
}
}
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 {
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() {
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(credentials: AliyunOSSCredentials) {
_ = NSClassFromString("NativeNui")
_ = credentials
}
}