// // V2vState.swift // SodaLive // // Created by klaus on 2/9/26. // import Foundation struct V2vState { var isAvailable: Bool = false var isCaptionOn: Bool = false var captionText: String = "" var agentId: String? = nil var sourceLanguage: String? = nil var targetLanguage: String? = nil } enum V2vLanguageMapper { static func mapToAgoraLanguage(_ code: String?) -> String? { guard let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { return nil } if normalized.hasPrefix("ko") { return "ko-KR" } if normalized.hasPrefix("ja") { return "ja-JP" } if normalized.hasPrefix("en") { return "en-US" } return nil } } enum V2vAppLanguageResolver { static func currentLanguageCode() -> String { let headerCode = LanguageHeaderProvider.current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if headerCode == "ko" || headerCode == "ja" || headerCode == "en" { return headerCode } let saved = UserDefaults.standard.string(forKey: "app.language") if let saved, let option = LanguageOption(rawValue: saved), option != .system { return option.rawValue } guard let preferred = Locale.preferredLanguages.first?.lowercased() else { return "ko" } if preferred.hasPrefix("ko") { return "ko" } if preferred.hasPrefix("ja") { return "ja" } return "en" } } final class V2vMessageAssembler { private struct Chunk { let messageId: String let partIdx: Int let partSum: Int let content: String } private struct Buffer { var partSum: Int var createdAt: Date var parts: [Int: String] } private var buffers: [String: Buffer] = [:] private let timeout: TimeInterval = 10 func consume(data: Data) -> String? { cleanupExpiredBuffers() guard let chunk = parseChunk(data: data) else { DEBUG_LOG("[V2V] chunk parsing failed. raw=\(preview(data))") return nil } DEBUG_LOG("[V2V] chunk received messageId=\(chunk.messageId), partIdx=\(chunk.partIdx), partSum=\(chunk.partSum), contentLength=\(chunk.content.count)") var buffer = buffers[chunk.messageId] ?? Buffer( partSum: chunk.partSum, createdAt: Date(), parts: [:] ) buffer.partSum = max(buffer.partSum, chunk.partSum) // 동일 partIdx는 중복 제거 if buffer.parts[chunk.partIdx] == nil { buffer.parts[chunk.partIdx] = chunk.content } buffers[chunk.messageId] = buffer guard buffer.parts.count >= buffer.partSum else { DEBUG_LOG("[V2V] chunk buffering \(chunk.messageId): \(buffer.parts.count)/\(buffer.partSum)") return nil } let sortedParts = buffer.parts .sorted { $0.key < $1.key } .prefix(buffer.partSum) .map { $0.value } guard sortedParts.count == buffer.partSum else { return nil } let combined = sortedParts.joined() DEBUG_LOG("[V2V] chunk assembled messageId=\(chunk.messageId), assembledLength=\(combined.count)") buffers[chunk.messageId] = nil return decodeCaptionText(from: combined) } func reset() { buffers.removeAll() } private func cleanupExpiredBuffers() { let now = Date() buffers = buffers.filter { _, value in now.timeIntervalSince(value.createdAt) <= timeout } } private func parseChunk(data: Data) -> Chunk? { if let text = String(data: data, encoding: .utf8) { if let parsed = parsePipeChunk(text) { return parsed } if let parsed = parseJSONChunk(text) { return parsed } } return nil } private func parsePipeChunk(_ raw: String) -> Chunk? { let items = raw.split(separator: "|", maxSplits: 3, omittingEmptySubsequences: false) guard items.count == 4, let partIdx = Int(items[1]), let partSum = Int(items[2]), partIdx >= 0, partSum > 0 else { return nil } return Chunk( messageId: String(items[0]), partIdx: partIdx, partSum: partSum, content: String(items[3]) ) } private func parseJSONChunk(_ raw: String) -> Chunk? { guard let data = raw.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let messageId = object["message_id"] as? String, let partIdx = anyToInt(object["part_idx"]), let partSum = anyToInt(object["part_sum"]), let content = object["content"] as? String, partIdx >= 0, partSum > 0 else { return nil } return Chunk( messageId: messageId, partIdx: partIdx, partSum: partSum, content: content ) } private func anyToInt(_ value: Any?) -> Int? { if let intValue = value as? Int { return intValue } if let stringValue = value as? String { return Int(stringValue) } if let number = value as? NSNumber { return number.intValue } return nil } private func decodeCaptionText(from base64String: String) -> String? { if let plainData = base64String.data(using: .utf8), let plainJson = try? JSONSerialization.jsonObject(with: plainData), let text = extractTranslationText(from: plainJson) { DEBUG_LOG("[V2V] plain json subtitle parsed") return text } guard let decodedData = decodeBase64Payload(base64String), let jsonObject = try? JSONSerialization.jsonObject(with: decodedData) else { DEBUG_LOG("[V2V] base64 or final json parsing failed. payloadPrefix=\(String(base64String.prefix(80)))") return nil } let text = extractTranslationText(from: jsonObject) if text == nil { DEBUG_LOG("[V2V] final json parsed but translation text not found") } return text } private func extractTranslationText(from object: Any) -> String? { if let dict = object as? [String: Any] { let eventType = (dict["object"] as? String) ?? (dict["type"] as? String) ?? (dict["event"] as? String) if let eventType, (eventType == "user.translation" || eventType == "agent.translation"), let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return text } for value in dict.values { if let text = extractTranslationText(from: value) { return text } } } if let array = object as? [Any] { for value in array { if let text = extractTranslationText(from: value) { return text } } } return nil } private func decodeBase64Payload(_ raw: String) -> Data? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if let data = Data(base64Encoded: trimmed) { return data } let normalized = trimmed .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let remainder = normalized.count % 4 let padded = remainder == 0 ? normalized : normalized + String(repeating: "=", count: 4 - remainder) return Data(base64Encoded: padded) } private func preview(_ data: Data) -> String { if let text = String(data: data, encoding: .utf8) { return String(text.prefix(80)) } return "\(data.count)bytes" } }