Files
mediaplayer/ios/OralTrainer/Services/ImitationAssessor.swift
2026-08-17 20:13:28 +08:00

125 lines
4.9 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.
import CryptoKit
import Foundation
enum ImitationAssessorError: LocalizedError {
case invalidHash
case cannotReadRecording
var errorDescription: String? {
switch self {
case .invalidHash:
return "视频 id 必须是 64 位 SHA-256 十六进制字符串"
case .cannotReadRecording:
return "无法读取录音文件"
}
}
}
enum ImitationAssessor {
/// SHA-256
static func sha256Hex(of fileURL: URL) throws -> String {
let handle: FileHandle
do {
handle = try FileHandle(forReadingFrom: fileURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? handle.close() }
var hasher = SHA256()
while true {
guard let data = try? handle.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// SHA-256
static func copyAndHash(from source: URL, to destination: URL) throws -> String {
let input: FileHandle
do {
input = try FileHandle(forReadingFrom: source)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? input.close() }
try? FileManager.default.removeItem(at: destination)
FileManager.default.createFile(atPath: destination.path, contents: nil)
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
var hasher = SHA256()
while true {
guard let data = try input.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
try output.write(contentsOf: data)
}
try output.synchronize()
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// MOSS multipart/form-data
static func assess(request: ImitationAssessmentRequest) async throws -> ImitationAssessmentResult {
let hash = request.mediaId.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw ImitationAssessorError.invalidHash
}
let path = "api/v1/videos/\(hash)/sentences/\(request.sentence.index)/assessments"
let boundary = "----OralTrainer-\(UUID().uuidString)"
var body = Data()
appendField(&body, boundary: boundary, name: "language", value: request.locale ?? request.sentence.language ?? "")
let recordingData: Data
do {
recordingData = try Data(contentsOf: request.recordingURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
let filename = request.recordingURL.lastPathComponent
let mimeType = request.recordingURL.pathExtension.lowercased() == "wav" ? "audio/wav" : "audio/m4a"
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
body.append(Data("--\(boundary)--\r\n".utf8))
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "POST",
timeout: AppConfig.assessmentTimeout,
headers: [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
"X-Client-Key": AppConfig.assessmentAPIKey,
],
body: body
)
guard (200...299).contains(result.statusCode) else {
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, responseBody)
}
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
}
private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
body.append(Data(value.utf8))
body.append(Data("\r\n".utf8))
}
private static func appendFile(
_ body: inout Data,
boundary: String,
name: String,
filename: String,
mimeType: String,
data: Data
) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
body.append(data)
body.append(Data("\r\n".utf8))
}
}