集成阿里云 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

@ -271,6 +271,68 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertEqual(store.scenicQueuePunchSpotId, "")
XCTAssertNil(store.scenicQueueSettingsSnapshot())
}
func testAliyunNLSTokenProviderBuildsSignedCreateTokenRequest() async throws {
let credentials = try MockScenicQueueAPI.makeSTS().credentials
let provider = AliyunNLSTokenProvider(
dateProvider: { Date(timeIntervalSince1970: 1_704_067_200) },
nonceProvider: { "nonce-1" },
dataLoader: { request in
let url = try XCTUnwrap(request.url)
let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
XCTAssertEqual(query.first(where: { $0.name == "Action" })?.value, "CreateToken")
XCTAssertEqual(query.first(where: { $0.name == "AccessKeyId" })?.value, "ak")
XCTAssertEqual(query.first(where: { $0.name == "SecurityToken" })?.value, "security-token")
XCTAssertEqual(query.first(where: { $0.name == "SignatureMethod" })?.value, "HMAC-SHA1")
XCTAssertNotNil(query.first(where: { $0.name == "Signature" })?.value)
return #"{"Token":{"Id":"nls-token","ExpireTime":1704068200}}"#.data(using: .utf8)!
}
)
let token = try await provider.createToken(credentials: credentials)
XCTAssertEqual(token.id, "nls-token")
XCTAssertEqual(token.expireTime, 1_704_068_200)
}
func testAliyunTTSManagerUsesNativeBridgeAndPlayerWhenPrepared() async {
let api = MockScenicQueueAPI()
let tokenProvider = MockAliyunNLSTokenProvider()
let bridge = MockAliyunNuiTTSBridge()
let player = MockAliyunPCMStreamingPlayer()
let manager = AliyunNLSTTSManager(
tokenProvider: tokenProvider,
bridge: bridge,
streamingPlayer: player
)
await manager.prepareQueueTTSEngine(api: api)
let ok = await manager.speakPlainTextOnce("A12号请到拍摄区域准备拍摄。")
XCTAssertTrue(ok)
XCTAssertTrue(bridge.events.contains { $0.hasPrefix("initialize:") })
XCTAssertTrue(bridge.events.contains("set:speech_rate=-120"))
XCTAssertTrue(bridge.events.contains("set:volume=2.0"))
XCTAssertTrue(bridge.events.contains("play:A12号请到拍摄区域准备拍摄。"))
XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"])
}
func testSettingsOfflineTTSSwitchReportsUnavailable() {
let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")!
defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS")
let store = AppStore(defaults: defaults)
store.userId = "u1"
store.accountType = "photog"
let tts = MockScenicQueueTTSManager()
let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
viewModel.setUseOfflineTTS(true)
XCTAssertFalse(store.scenicQueueUseOfflineTTS)
XCTAssertEqual(messages.last, "离线语音资源未安装")
}
}
@MainActor
@ -355,8 +417,9 @@ private final class MockScenicQueueAPI: ScenicQueueAPIProtocol {
private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging {
private(set) var events: [String] = []
private(set) var customTextLooping = false
let supportsOfflineTTS = false
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async {
func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async {
events.append("prepare")
}
@ -393,6 +456,76 @@ private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging {
}
}
private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding {
func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken {
AliyunNLSToken(id: "mock-nls-token", expireTime: 1_704_068_200)
}
}
private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging {
weak var delegate: AliyunNuiTTSBridgeDelegate?
var sdkAvailable = true
private(set) var events: [String] = []
private let callbackBridge = AliyunNuiTTSBridge()
func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int {
events.append("initialize:\(ticket.contains("mock-nls-token"))")
return 0
}
func setParam(_ param: String, value: String) -> Int {
events.append("set:\(param)=\(value)")
return 0
}
func play(priority: String, taskId: String, text: String) -> Int {
events.append("play:\(text)")
delegate?.aliyunNuiTTSBridge(
callbackBridge,
didReceiveAudioData: Data([0, 0, 1, 0]),
taskId: taskId,
info: nil
)
delegate?.aliyunNuiTTSBridge(callbackBridge, didReceiveEvent: 1, taskId: taskId, code: 0)
return 0
}
func cancel(taskId: String?) -> Int {
events.append("cancel:\(taskId ?? "")")
return 0
}
func releaseEngine() -> Int {
events.append("release")
return 0
}
func paramValue(for key: String) -> String? {
nil
}
}
private final class MockAliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying {
private(set) var events: [String] = []
func start(sampleRate: Double) {
events.append("start:\(Int(sampleRate))")
}
func write(_ data: Data) {
events.append("write:\(data.count)")
}
func drain(_ completion: @escaping () -> Void) {
events.append("drain")
completion()
}
func stop() {
events.append("stop")
}
}
private extension ScenicQueueHomeData {
static func page(items: [ScenicQueueHomeItem], total: Int, page: Int) -> ScenicQueueHomeData {
ScenicQueueHomeData(