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