集成阿里云 NUI 语音合成 SDK

This commit is contained in:
2026-07-07 16:05:39 +08:00
parent 0aa8b14e1f
commit 9378748a4d
26 changed files with 2095 additions and 16 deletions

View File

@ -0,0 +1,135 @@
//
// AliyunNLSTokenProvider.swift
// suixinkan
//
import CryptoKit
import Foundation
/// NLS token 访
struct AliyunNLSToken: Equatable {
let id: String
let expireTime: Int64?
}
/// NLS token 便 TTS
protocol AliyunNLSTokenProviding {
/// 使 STS NLS token
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken
}
/// NLS token Provider OpenAPI POP CreateToken
final class AliyunNLSTokenProvider: AliyunNLSTokenProviding {
typealias DataLoader = (URLRequest) async throws -> Data
private let endpoint: URL
private let dateProvider: () -> Date
private let nonceProvider: () -> String
private let dataLoader: DataLoader
init(
endpoint: URL = URL(string: "https://nls-meta.cn-shanghai.aliyuncs.com/")!,
dateProvider: @escaping () -> Date = Date.init,
nonceProvider: @escaping () -> String = { UUID().uuidString },
dataLoader: @escaping DataLoader = { request in
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw AliyunNLSTokenError.httpStatus(http.statusCode)
}
return data
}
) {
self.endpoint = endpoint
self.dateProvider = dateProvider
self.nonceProvider = nonceProvider
self.dataLoader = dataLoader
}
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken {
var params = signedBaseParams(credentials: credentials)
params["Signature"] = Self.signature(params: params, accessKeySecret: credentials.accessKeySecret)
let query = Self.canonicalQuery(params)
guard let url = URL(string: endpoint.absoluteString + "?" + query) else {
throw AliyunNLSTokenError.invalidURL
}
let request = URLRequest(url: url)
let data = try await dataLoader(request)
let response = try JSONDecoder().decode(AliyunNLSCreateTokenResponse.self, from: data)
guard let id = response.token?.id?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty else {
throw AliyunNLSTokenError.missingToken
}
return AliyunNLSToken(id: id, expireTime: response.token?.expireTime)
}
/// CreateToken
func signedBaseParams(credentials: AliyunOSSCredentials) -> [String: String] {
[
"AccessKeyId": credentials.accessKeyId,
"Action": "CreateToken",
"Format": "JSON",
"RegionId": "cn-shanghai",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": nonceProvider(),
"SignatureVersion": "1.0",
"Timestamp": Self.timestampFormatter.string(from: dateProvider()),
"Version": "2019-02-28",
"SecurityToken": credentials.securityToken,
]
}
/// POP HMAC-SHA1
static func signature(params: [String: String], accessKeySecret: String) -> String {
let canonical = canonicalQuery(params)
let stringToSign = "GET&%2F&\(percentEncode(canonical))"
let key = SymmetricKey(data: Data("\(accessKeySecret)&".utf8))
let mac = HMAC<Insecure.SHA1>.authenticationCode(for: Data(stringToSign.utf8), using: key)
return Data(mac).base64EncodedString()
}
/// OpenAPI
static func canonicalQuery(_ params: [String: String]) -> String {
params
.sorted { $0.key < $1.key }
.map { "\(percentEncode($0.key))=\(percentEncode($0.value))" }
.joined(separator: "&")
}
/// POP RFC3986
static func percentEncode(_ value: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-_.~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
private static let timestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}()
}
/// NLS token
enum AliyunNLSTokenError: Error, Equatable {
case invalidURL
case httpStatus(Int)
case missingToken
}
private struct AliyunNLSCreateTokenResponse: Decodable {
let token: TokenPayload?
enum CodingKeys: String, CodingKey {
case token = "Token"
}
struct TokenPayload: Decodable {
let id: String?
let expireTime: Int64?
enum CodingKeys: String, CodingKey {
case id = "Id"
case expireTime = "ExpireTime"
}
}
}

View File

@ -0,0 +1,59 @@
//
// AliyunNuiTTSBridge.h
// suixinkan
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 阿里云 NUI TTS 事件代理,将 Objective-C SDK 回调转成稳定的 Swift 边界。
@protocol AliyunNuiTTSBridgeDelegate;
/// 阿里云 NUI TTS Objective-C 桥接器,隔离 SDK 头文件和模拟器不可用逻辑。
@interface AliyunNuiTTSBridge : NSObject
/// SDK 回调代理。
@property (nonatomic, weak, nullable) id<AliyunNuiTTSBridgeDelegate> delegate;
/// 当前平台是否可直接加载 NUI TTS SDK。
@property (nonatomic, readonly) BOOL sdkAvailable;
/// 初始化 NUI TTS 引擎。
- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog;
/// 设置合成参数。
- (NSInteger)setParam:(NSString *)param value:(NSString *)value;
/// 开始合成指定文本。
- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text;
/// 取消指定任务taskId 为空时取消全部任务。
- (NSInteger)cancelWithTaskId:(nullable NSString *)taskId;
/// 释放 SDK 资源。
- (NSInteger)releaseEngine;
/// 获取 SDK 参数值。
- (nullable NSString *)paramValueForKey:(NSString *)key;
@end
/// 阿里云 NUI TTS 事件代理。
@protocol AliyunNuiTTSBridgeDelegate <NSObject>
/// 收到 TTS 状态事件。
- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge
didReceiveEvent:(NSInteger)event
taskId:(nullable NSString *)taskId
code:(NSInteger)code;
/// 收到 TTS 音频数据。
- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge
didReceiveAudioData:(NSData *)data
taskId:(nullable NSString *)taskId
info:(nullable NSString *)info;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,138 @@
//
// AliyunNuiTTSBridge.m
// suixinkan
//
#import "AliyunNuiTTSBridge.h"
#import <TargetConditionals.h>
#if !TARGET_OS_SIMULATOR && __has_include(<nuisdk/NeoNuiTts.h>)
#import <nuisdk/NeoNuiTts.h>
#define ALIYUN_NUI_TTS_AVAILABLE 1
#else
#define ALIYUN_NUI_TTS_AVAILABLE 0
#endif
#if ALIYUN_NUI_TTS_AVAILABLE
@interface AliyunNuiTTSBridge () <NeoNuiTtsDelegate>
@property (nonatomic, strong, nullable) NeoNuiTts *nui;
@end
#else
@interface AliyunNuiTTSBridge ()
@end
#endif
@implementation AliyunNuiTTSBridge
- (BOOL)sdkAvailable {
#if ALIYUN_NUI_TTS_AVAILABLE
return YES;
#else
return NO;
#endif
}
- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui != nil) {
[self.nui nui_tts_release];
self.nui.delegate = nil;
self.nui = nil;
}
NeoNuiTts *nui = [NeoNuiTts get_instance];
nui.delegate = self;
NSInteger code = [nui nui_tts_initialize:[ticket UTF8String]
logLevel:(NuiSdkLogLevel)logLevel
saveLog:saveLog];
if (code == 0) {
self.nui = nui;
} else {
nui.delegate = nil;
[nui nui_tts_release];
}
return code;
#else
(void)ticket;
(void)logLevel;
(void)saveLog;
return -1;
#endif
}
- (NSInteger)setParam:(NSString *)param value:(NSString *)value {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui == nil) { return -1; }
return [self.nui nui_tts_set_param:[param UTF8String] value:[value UTF8String]];
#else
(void)param;
(void)value;
return -1;
#endif
}
- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui == nil) { return -1; }
return [self.nui nui_tts_play:[priority UTF8String] taskId:[taskId UTF8String] text:[text UTF8String]];
#else
(void)priority;
(void)taskId;
(void)text;
return -1;
#endif
}
- (NSInteger)cancelWithTaskId:(NSString *)taskId {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui == nil) { return -1; }
const char *task = taskId.length > 0 ? [taskId UTF8String] : NULL;
return [self.nui nui_tts_cancel:task];
#else
(void)taskId;
return -1;
#endif
}
- (NSInteger)releaseEngine {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui == nil) { return 0; }
NSInteger code = [self.nui nui_tts_release];
self.nui.delegate = nil;
self.nui = nil;
return code;
#else
return 0;
#endif
}
- (NSString *)paramValueForKey:(NSString *)key {
#if ALIYUN_NUI_TTS_AVAILABLE
if (self.nui == nil) { return nil; }
const char *value = [self.nui nui_tts_get_param:[key UTF8String]];
if (value == NULL) { return nil; }
return [NSString stringWithUTF8String:value];
#else
(void)key;
return nil;
#endif
}
#if ALIYUN_NUI_TTS_AVAILABLE
- (void)onNuiTtsEventCallback:(NuiSdkTtsEvent)event taskId:(char *)taskid code:(int)code {
NSString *taskId = taskid != NULL ? [NSString stringWithUTF8String:taskid] : nil;
[self.delegate aliyunNuiTTSBridge:self didReceiveEvent:event taskId:taskId code:code];
}
- (void)onNuiTtsUserdataCallback:(char *)info infoLen:(int)info_len buffer:(char *)buffer len:(int)len taskId:(char *)task_id {
if (buffer == NULL || len <= 0) { return; }
NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)len];
NSString *taskId = task_id != NULL ? [NSString stringWithUTF8String:task_id] : nil;
NSString *infoText = nil;
if (info != NULL && info_len > 0) {
infoText = [[NSString alloc] initWithBytes:info length:(NSUInteger)info_len encoding:NSUTF8StringEncoding];
}
[self.delegate aliyunNuiTTSBridge:self didReceiveAudioData:data taskId:taskId info:infoText];
}
#endif
@end

View File

@ -0,0 +1,130 @@
//
// AliyunPCMStreamingPlayer.swift
// suixinkan
//
import AVFoundation
import Foundation
/// TTS PCM 便
protocol AliyunPCMStreamingPlaying: AnyObject {
/// 16-bit mono PCM
func start(sampleRate: Double)
/// 16-bit little-endian mono PCM
func write(_ data: Data)
///
func drain(_ completion: @escaping () -> Void)
///
func stop()
}
/// AVAudioEngine 16-bit PCM
final class AliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
private let stateQueue = DispatchQueue(label: "com.zhifly.suixinkan.aliyunNLS.pcmPlayer")
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private var format: AVAudioFormat?
private var scheduledBufferCount = 0
private var draining = false
private var drainCompletion: (() -> Void)?
private var started = false
func start(sampleRate: Double) {
stateQueue.sync {
configureSession()
if engine.attachedNodes.contains(playerNode) == false {
engine.attach(playerNode)
}
let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)
format = audioFormat
if let audioFormat {
engine.disconnectNodeOutput(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat)
}
if engine.isRunning == false {
try? engine.start()
}
if playerNode.isPlaying == false {
playerNode.play()
}
started = true
draining = false
drainCompletion = nil
}
}
func write(_ data: Data) {
guard data.count >= 2 else { return }
stateQueue.async {
guard self.started, let format = self.format else { return }
let buffer = Self.makePCMBuffer(from: data, format: format)
guard let buffer else { return }
self.scheduledBufferCount += 1
self.playerNode.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
self?.stateQueue.async {
guard let self else { return }
self.scheduledBufferCount = max(0, self.scheduledBufferCount - 1)
self.completeDrainIfNeeded()
}
}
}
}
func drain(_ completion: @escaping () -> Void) {
stateQueue.async {
self.draining = true
self.drainCompletion = completion
self.completeDrainIfNeeded()
}
}
func stop() {
stateQueue.sync {
playerNode.stop()
engine.stop()
scheduledBufferCount = 0
draining = false
drainCompletion = nil
started = false
}
}
private func completeDrainIfNeeded() {
guard draining, scheduledBufferCount == 0 else { return }
let completion = drainCompletion
drainCompletion = nil
draining = false
started = false
playerNode.stop()
completion?()
}
private func configureSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, options: [.allowBluetoothHFP, .mixWithOthers])
try? session.setActive(true)
}
private static func makePCMBuffer(from data: Data, format: AVAudioFormat) -> AVAudioPCMBuffer? {
let frameCount = data.count / MemoryLayout<Int16>.size
guard frameCount > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)),
let channel = buffer.floatChannelData?[0] else {
return nil
}
buffer.frameLength = AVAudioFrameCount(frameCount)
data.withUnsafeBytes { raw in
let bytes = raw.bindMemory(to: UInt8.self)
for index in 0..<frameCount {
let low = UInt16(bytes[index * 2])
let high = UInt16(bytes[index * 2 + 1]) << 8
let sample = Int16(bitPattern: high | low)
channel[index] = Float(sample) / Float(Int16.max)
}
}
return buffer
}
}

View File

@ -11,8 +11,11 @@ protocol ScenicQueueTTSManaging: AnyObject {
///
var customTextLooping: Bool { get }
/// 线
var supportsOfflineTTS: Bool { get }
/// TTS
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async
///
func startQueueCallLoop(queueNo: String)
@ -38,10 +41,7 @@ protocol ScenicQueueTTSManaging: AnyObject {
func estimateAnnouncementDurationMillis(_ text: String) -> Int64
}
/// NLS TTS
///
/// iOS NativeNui SDK STS/NLS SDK
/// SDK framework `prepareNativeEngineIfAvailable` NativeNui
/// NLS TTS 使 NUI SDK退
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
static let shared = AliyunNLSTTSManager()
@ -52,27 +52,50 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
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 nlsToken: String?
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
override init() {
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: ScenicQueueAPI?) async {
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
guard let api else { return }
guard !appStore.scenicQueueUseOfflineTTS || supportsOfflineTTS else {
nativeReady = false
return
}
do {
let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
nlsToken = nil
prepareNativeEngineIfAvailable(credentials: sts.credentials)
let token = try await tokenProvider.createToken(credentials: sts.credentials)
nlsToken = token
nativeReady = await prepareNativeEngineIfAvailable(token: token)
} catch {
// Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails.
nativeReady = false
}
}
@ -182,6 +205,35 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
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)
@ -200,6 +252,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
}
private func stopSpeaking() {
nativeQueue.async { [weak self] in
self?.cancelNativeSpeakLocked(resumeExisting: true)
}
DispatchQueue.main.async {
if self.synthesizer.isSpeaking {
self.synthesizer.stopSpeaking(at: .immediate)
@ -221,8 +276,200 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth
resumeCurrentContinuationIfNeeded()
}
private func prepareNativeEngineIfAvailable(credentials: AliyunOSSCredentials) {
_ = NSClassFromString("NativeNui")
_ = credentials
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
}
}

View File

@ -103,6 +103,11 @@ final class ScenicQueueSettingsViewModel {
return "请选择打卡点"
}
/// 使线
var useOfflineTTS: Bool {
appStore.scenicQueueUseOfflineTTS
}
func openSettingChangeLog() {
onNavigateLog?()
}
@ -170,6 +175,23 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
}
/// 线
func setUseOfflineTTS(_ enabled: Bool) {
guard enabled else {
appStore.scenicQueueUseOfflineTTS = false
notifyStateChange()
return
}
guard ttsManager.supportsOfflineTTS else {
appStore.scenicQueueUseOfflineTTS = false
onShowMessage?("离线语音资源未安装")
notifyStateChange()
return
}
appStore.scenicQueueUseOfflineTTS = true
notifyStateChange()
}
func setCustomTTSText(_ value: String) {
customTTSText = String(value.prefix(Limits.remarkMaxLen))
notifyStateChange()

View File

@ -87,7 +87,7 @@ final class ScenicQueueViewModel {
await refreshQueueStats(api: api)
await refreshCurrentQueue(api: api, reset: true)
await refreshSkippedQueue(api: api, reset: true)
await ttsManager.prepareQueueTTSEngine(api: api as? ScenicQueueAPI)
await ttsManager.prepareQueueTTSEngine(api: api)
} else {
resetQueues()
onRequireSettings?()

View File

@ -166,6 +166,7 @@ final class ScenicQueueSettingsViewController: BaseViewController {
private func buildVoiceForm() {
addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "", keyboard: .numberPad, action: #selector(broadcastChanged(_:)))
addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "", keyboard: .numberPad, action: #selector(countdownChanged(_:)))
addSwitchRow(key: "offlineTTS", title: "离线语音合成", isOn: viewModel.useOfflineTTS, action: #selector(offlineTTSChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil))
addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:)))
stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped)))
@ -329,6 +330,7 @@ final class ScenicQueueSettingsViewController: BaseViewController {
@objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) }
@objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) }
@objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) }
@objc private func offlineTTSChanged(_ sender: UISwitch) { viewModel.setUseOfflineTTS(sender.isOn) }
@objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") }
}

View File

@ -3,11 +3,26 @@
// suixinkan
//
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
#import <AMapFoundationKit/AMapFoundationKit.h>
#endif
#if __has_include(<AMapLocationKit/AMapLocationKit.h>)
#import <AMapLocationKit/AMapLocationKit.h>
#endif
#if __has_include(<MAMapKit/MAMapKit.h>)
#import <MAMapKit/MAMapKit.h>
#endif
#if __has_include(<AMapSearchKit/AMapSearchKit.h>)
#import <AMapSearchKit/AMapSearchKit.h>
#endif
#if __has_include(<UMCommon/UMCommon.h>)
#import <UMCommon/UMCommon.h>
#endif
#import "Features/ScenicQueue/Services/AliyunNuiTTSBridge.h"
#if __has_include(<UMAPM/UMAPM.h>)
#import <UMAPM/UMAPM.h>