2 changed files with 368 additions and 3 deletions
@ -0,0 +1,280 @@ |
|||||
|
import Foundation |
||||
|
import CryptoKit |
||||
|
|
||||
|
class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate { |
||||
|
private let tag = "XunFeiAsrHelper" |
||||
|
|
||||
|
private let appId = "43d87d22" |
||||
|
private let accessKeyId = "2bd01ce06ac35957fc0c082f9b6284ec" |
||||
|
private let accessKeySecret = "ODU3OTUyYjhjMjY5NDhkMmMxYWY2Zjlm" |
||||
|
|
||||
|
private var webSocketTask: URLSessionWebSocketTask? |
||||
|
private lazy var session: URLSession = { |
||||
|
let configuration = URLSessionConfiguration.default |
||||
|
return URLSession(configuration: configuration, delegate: self, delegateQueue: OperationQueue()) |
||||
|
}() |
||||
|
|
||||
|
private var currentSessionId = "" |
||||
|
private var sessionId = "" |
||||
|
private var lastIntermediateResult = "" |
||||
|
|
||||
|
private var currentLanguage = "zh-CN" |
||||
|
private var isAutoDetectLanguage = false |
||||
|
|
||||
|
private var callback: AzureAsrHelper.ContinuousRecognizeCallback? |
||||
|
|
||||
|
func start(callback: AzureAsrHelper.ContinuousRecognizeCallback, language: String, isAutoDetect: Bool) { |
||||
|
stop() |
||||
|
|
||||
|
self.callback = callback |
||||
|
self.currentLanguage = language |
||||
|
self.isAutoDetectLanguage = isAutoDetect |
||||
|
self.currentSessionId = UUID().uuidString |
||||
|
self.lastIntermediateResult = "" |
||||
|
self.sessionId = "" |
||||
|
|
||||
|
let urlString = getWebSocketUrl() |
||||
|
guard let url = URL(string: urlString) else { |
||||
|
print("\(tag): Invalid URL") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let request = URLRequest(url: url) |
||||
|
webSocketTask = session.webSocketTask(with: request) |
||||
|
webSocketTask?.resume() |
||||
|
|
||||
|
print("\(tag): WebSocket Connecting...") |
||||
|
receiveMessage() |
||||
|
} |
||||
|
|
||||
|
func sendAudio(frameBuffer: Data) { |
||||
|
guard let webSocketTask = webSocketTask else { return } |
||||
|
let message = URLSessionWebSocketTask.Message.data(frameBuffer) |
||||
|
webSocketTask.send(message) { error in |
||||
|
if let error = error { |
||||
|
print("\(self.tag): Send audio failed: \(error)") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func stop() { |
||||
|
if let webSocketTask = webSocketTask { |
||||
|
let endJson = "{\"end\": true, \"sessionId\": \"\(sessionId)\"}" |
||||
|
let message = URLSessionWebSocketTask.Message.string(endJson) |
||||
|
webSocketTask.send(message) { _ in } |
||||
|
webSocketTask.cancel(with: .normalClosure, reason: "User stopped".data(using: .utf8)) |
||||
|
} |
||||
|
webSocketTask = nil |
||||
|
} |
||||
|
|
||||
|
private func receiveMessage() { |
||||
|
webSocketTask?.receive { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
switch result { |
||||
|
case .failure(let error): |
||||
|
print("\(self.tag): WebSocket Receive Error: \(error)") |
||||
|
self.callback?.onError(sessionid: self.currentSessionId, 1004, "Xunfei connection failed: \(error.localizedDescription)") |
||||
|
|
||||
|
case .success(let message): |
||||
|
switch message { |
||||
|
case .string(let text): |
||||
|
self.processMessage(text) |
||||
|
case .data(let data): |
||||
|
if let text = String(data: data, encoding: .utf8) { |
||||
|
self.processMessage(text) |
||||
|
} |
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
// Continue receiving messages |
||||
|
self.receiveMessage() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { |
||||
|
print("\(tag): WebSocket Opened") |
||||
|
callback?.onSessionStarted(sessionid: currentSessionId) |
||||
|
} |
||||
|
|
||||
|
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { |
||||
|
let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown" |
||||
|
print("\(tag): WebSocket Closed: \(closeCode) / \(reasonString)") |
||||
|
|
||||
|
if !currentSessionId.isEmpty && !lastIntermediateResult.isEmpty { |
||||
|
let detectedLanguage = detectLanguage(from: lastIntermediateResult) |
||||
|
callback?.onResult(sessionid: currentSessionId, lastIntermediateResult, detectedLanguage) |
||||
|
currentSessionId = "" |
||||
|
} |
||||
|
callback?.onSessionStopped(sessionid: currentSessionId) |
||||
|
} |
||||
|
|
||||
|
private func processMessage(_ text: String) { |
||||
|
guard let data = text.data(using: .utf8) else { return } |
||||
|
|
||||
|
do { |
||||
|
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return } |
||||
|
print("\(tag): -------------------\(json)") |
||||
|
|
||||
|
let msgType = json["msg_type"] as? String |
||||
|
|
||||
|
switch msgType { |
||||
|
case "action": |
||||
|
if let sid = json["sessionId"] as? String, !sid.isEmpty { |
||||
|
self.sessionId = sid |
||||
|
} |
||||
|
print("\(tag): Handshake success") |
||||
|
|
||||
|
case "result": |
||||
|
processResult(json) |
||||
|
|
||||
|
case "error": |
||||
|
let errorMsg = "\(json)" |
||||
|
print("\(tag): Error: \(errorMsg)") |
||||
|
callback?.onError(sessionid: currentSessionId, 1005, "Xunfei error: \(errorMsg)") |
||||
|
|
||||
|
default: |
||||
|
print("\(tag): Unknown msg_type: \(msgType ?? "nil")") |
||||
|
callback?.onError(sessionid: currentSessionId, 1006, "Xunfei unknown msg_type: \(msgType ?? "nil")") |
||||
|
} |
||||
|
|
||||
|
} catch { |
||||
|
print("\(tag): Parse error: \(error)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func processResult(_ json: [String: Any]) { |
||||
|
guard let dataStr = json["data"] as? String, |
||||
|
let dataData = dataStr.data(using: .utf8), |
||||
|
let data = try? JSONSerialization.jsonObject(with: dataData, options: []) as? [String: Any] else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let ls = data["ls"] as? Bool ?? false |
||||
|
guard let cn = data["cn"] as? [String: Any], |
||||
|
let st = cn["st"] as? [String: Any], |
||||
|
let rt = st["rt"] as? [[String: Any]] else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
var resultTextTemp = "" |
||||
|
var punctuationCount = 0 |
||||
|
var wordCount = 0 |
||||
|
|
||||
|
for j in rt { |
||||
|
guard let ws = j["ws"] as? [[String: Any]] else { continue } |
||||
|
for kObj in ws { |
||||
|
guard let cw = kObj["cw"] as? [[String: Any]] else { continue } |
||||
|
|
||||
|
for lObj in cw { |
||||
|
let w = lObj["w"] as? String ?? "" |
||||
|
let wp = lObj["wp"] as? String ?? "" |
||||
|
|
||||
|
if wp == "p" { |
||||
|
punctuationCount += 1 |
||||
|
if resultTextTemp.isEmpty { |
||||
|
continue |
||||
|
} |
||||
|
} else { |
||||
|
wordCount += 1 |
||||
|
} |
||||
|
resultTextTemp += w |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if ls && wordCount == 0 && punctuationCount == 1 { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let detectedLanguage = detectLanguage(from: resultTextTemp) |
||||
|
|
||||
|
let type = st["type"] as? String |
||||
|
let typeInt = st["type"] as? Int ?? Int(type ?? "") ?? -1 |
||||
|
|
||||
|
if typeInt == 0 { |
||||
|
callback?.onResult(sessionid: currentSessionId, resultTextTemp, detectedLanguage) |
||||
|
currentSessionId = "" |
||||
|
lastIntermediateResult = "" |
||||
|
} else { |
||||
|
if currentSessionId.isEmpty { |
||||
|
currentSessionId = UUID().uuidString |
||||
|
callback?.onSessionStarted(sessionid: currentSessionId) |
||||
|
} |
||||
|
lastIntermediateResult = resultTextTemp |
||||
|
callback?.onRecognizing(sessionid: currentSessionId, resultTextTemp, detectedLanguage) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func detectLanguage(from text: String) -> String { |
||||
|
if isAutoDetectLanguage { |
||||
|
if text.range(of: "[\\u4e00-\\u9fa5]", options: .regularExpression) != nil { |
||||
|
return "zh-CN" |
||||
|
} else { |
||||
|
return "en-US" |
||||
|
} |
||||
|
} else { |
||||
|
return currentLanguage |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func getWebSocketUrl() -> String { |
||||
|
let baseWsUrl = "wss://office-api-ast-dx.iflyaisol.com/ast/communicate/v1" |
||||
|
|
||||
|
var params: [String: String] = [:] |
||||
|
params["audio_encode"] = "pcm_s16le" |
||||
|
params["lang"] = "autodialect" |
||||
|
params["samplerate"] = "16000" |
||||
|
params["accessKeyId"] = accessKeyId |
||||
|
params["appId"] = appId |
||||
|
params["utc"] = getUtcTime() |
||||
|
|
||||
|
let signature = calculateSignature(params: params) |
||||
|
params["signature"] = signature |
||||
|
|
||||
|
var urlComponents = URLComponents(string: baseWsUrl) |
||||
|
urlComponents?.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } |
||||
|
|
||||
|
return urlComponents?.url?.absoluteString ?? baseWsUrl |
||||
|
} |
||||
|
|
||||
|
private func getUtcTime() -> String { |
||||
|
let dateFormatter = DateFormatter() |
||||
|
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" |
||||
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 8 * 3600) // GMT+8 |
||||
|
return dateFormatter.string(from: Date()) |
||||
|
} |
||||
|
|
||||
|
private func calculateSignature(params: [String: String]) -> String { |
||||
|
let sortedKeys = params.keys.sorted() |
||||
|
var baseStr = "" |
||||
|
var first = true |
||||
|
|
||||
|
for key in sortedKeys { |
||||
|
if key == "signature" { continue } |
||||
|
guard let value = params[key], !value.isEmpty else { continue } |
||||
|
|
||||
|
if !first { |
||||
|
baseStr += "&" |
||||
|
} |
||||
|
|
||||
|
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key |
||||
|
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value |
||||
|
|
||||
|
baseStr += "\(encodedKey)=\(encodedValue)" |
||||
|
first = false |
||||
|
} |
||||
|
|
||||
|
let keyData = accessKeySecret.data(using: .utf8)! |
||||
|
let msgData = baseStr.data(using: .utf8)! |
||||
|
|
||||
|
if #available(iOS 13.0, *) { |
||||
|
let authentication = HMAC<Insecure.SHA1>.authenticationCode(for: msgData, using: SymmetricKey(data: keyData)) |
||||
|
return Data(authentication).base64EncodedString() |
||||
|
} else { |
||||
|
return "" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue