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)) } }