集成阿里云 NUI 语音合成 SDK
This commit is contained in:
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user