Files
mediaplayer/ios/OralTrainer/Services/ImitationAssessor.swift
2026-09-05 13:09:39 +08:00

226 lines
9.1 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 "无法读取录音文件"
}
}
}
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))
var headers = [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/dub-shares",
method: "POST",
timeout: 120,
headers: headers,
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))
}
}