라이브룸에서 진행자 언어와 기기 언어가 다를 때 자막 토글을 제공한다. 룸 정보 응답에 V2V 워커 토큰과 진행자 언어 코드를 포함한다. Agora V2V 에이전트 참여와 종료 API 연동을 추가한다
275 lines
8.1 KiB
Swift
275 lines
8.1 KiB
Swift
//
|
|
// 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"
|
|
}
|
|
}
|