222 lines
9.0 KiB
Swift
222 lines
9.0 KiB
Swift
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 "无法读取录音文件"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct DubShareUploadRequest {
|
||
let videoHash: String
|
||
let title: String
|
||
let segments: [(index: Int, audioURL: URL)]
|
||
let scores: [Int: ImitationAssessmentResult]
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
static func uploadDubShare(request: DubShareUploadRequest) async throws -> String {
|
||
let hash = request.videoHash.lowercased()
|
||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||
throw ImitationAssessorError.invalidHash
|
||
}
|
||
guard !request.segments.isEmpty else {
|
||
throw ImitationAssessorError.cannotReadRecording
|
||
}
|
||
|
||
let boundary = "----OralTrainer-\(UUID().uuidString)"
|
||
var body = Data()
|
||
appendField(&body, boundary: boundary, name: "video_hash", value: hash)
|
||
appendField(&body, boundary: boundary, name: "title", value: request.title.isEmpty ? "我的口语配音" : request.title)
|
||
|
||
let segmentItems = request.segments
|
||
.sorted { $0.index < $1.index }
|
||
.map { "{\"sentence_index\":\($0.index)}" }
|
||
appendField(&body, boundary: boundary, name: "segments", value: "[\(segmentItems.joined(separator: ","))]")
|
||
|
||
let scoreItems = request.segments
|
||
.sorted { $0.index < $1.index }
|
||
.compactMap { segment -> String? in
|
||
guard let score = request.scores[segment.index] else { return nil }
|
||
var fields = ["\"sentence_index\":\(segment.index)"]
|
||
for (key, value) in [
|
||
("overall_score", score.overallScore as Float?),
|
||
("content_score", score.contentScore),
|
||
("fluency_score", score.fluencyScore),
|
||
("duration_score", score.durationScore),
|
||
("pause_score", score.pauseScore),
|
||
("speech_rate_score", score.speechRateScore),
|
||
] {
|
||
if let value = value {
|
||
fields.append("\"\(key)\":\(value)")
|
||
}
|
||
}
|
||
if let text = score.recognizedText {
|
||
let escaped = text
|
||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||
fields.append("\"recognized_text\":\"\(escaped)\"")
|
||
}
|
||
return "{\(fields.joined(separator: ","))}"
|
||
}
|
||
appendField(&body, boundary: boundary, name: "scores", value: "[\(scoreItems.joined(separator: ","))]")
|
||
|
||
for segment in request.segments.sorted(by: { $0.index < $1.index }) {
|
||
let audioData: Data
|
||
do {
|
||
audioData = try Data(contentsOf: segment.audioURL)
|
||
} catch {
|
||
throw ImitationAssessorError.cannotReadRecording
|
||
}
|
||
appendFile(
|
||
&body,
|
||
boundary: boundary,
|
||
name: "files",
|
||
filename: "dub-\(segment.index).m4a",
|
||
mimeType: "audio/mp4",
|
||
data: audioData
|
||
)
|
||
}
|
||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||
|
||
let result = try await HTTPClient.execute(
|
||
baseURL: AppConfig.serverBaseURL,
|
||
path: "api/v1/dub-shares",
|
||
method: "POST",
|
||
timeout: 120,
|
||
headers: [
|
||
"Accept": "application/json",
|
||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||
],
|
||
body: body
|
||
)
|
||
guard (200...299).contains(result.statusCode) else {
|
||
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
|
||
throw HTTPClientError.badStatus(result.statusCode, responseBody)
|
||
}
|
||
struct ShareResponse: Decodable {
|
||
let shareId: String
|
||
private enum CodingKeys: String, CodingKey {
|
||
case shareId = "share_id"
|
||
}
|
||
}
|
||
let response = try JSONDecoder().decode(ShareResponse.self, from: result.body)
|
||
let base = AppConfig.serverBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
return "\(base)/dub-shares/\(response.shareId)"
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|