|
|
|
@ -32,9 +32,16 @@ public final class SonioxLiveSession { |
|
|
|
public var onNetworkDegraded: ((Bool) -> Void)? |
|
|
|
|
|
|
|
public private(set) var hasNetworkFailure: Bool = false |
|
|
|
public private(set) var isRecordingActive: Bool = false |
|
|
|
private var lastDataTime: Date = Date() |
|
|
|
private var heartbeatTimer: Timer? |
|
|
|
|
|
|
|
public func setRecordingActive(_ active: Bool) { |
|
|
|
lock.lock() |
|
|
|
isRecordingActive = active |
|
|
|
lock.unlock() |
|
|
|
} |
|
|
|
|
|
|
|
public var isReady: Bool { |
|
|
|
lock.lock() |
|
|
|
defer { lock.unlock() } |
|
|
|
@ -148,6 +155,7 @@ public final class SonioxLiveSession { |
|
|
|
print("SonioxLiveSession: Error streaming audio chunk:", error) |
|
|
|
self?.lock.lock() |
|
|
|
self?.hasNetworkFailure = true |
|
|
|
self?.isConnected = false |
|
|
|
self?.lock.unlock() |
|
|
|
self?.onNetworkDegraded?(true) |
|
|
|
} |
|
|
|
@ -228,9 +236,18 @@ public final class SonioxLiveSession { |
|
|
|
print("SonioxLiveSession: WebSocket receive error:", error) |
|
|
|
self.lock.lock() |
|
|
|
self.hasNetworkFailure = true |
|
|
|
self.isConnected = false |
|
|
|
let activeRecording = self.isRecordingActive |
|
|
|
self.lock.unlock() |
|
|
|
self.onNetworkDegraded?(true) |
|
|
|
|
|
|
|
// If recording is still active (user speaking), keep buffering in AudioRecorder; |
|
|
|
// do NOT complete or deliver empty text prematurely! |
|
|
|
if !activeRecording { |
|
|
|
self.completeWithCurrentText() |
|
|
|
} else { |
|
|
|
print("SonioxLiveSession: Network dropped mid-recording. Audio is buffered for Smart Recovery upon key release.") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -321,6 +338,7 @@ public final class SonioxLiveSession { |
|
|
|
let fullNonFinal = currentNonFinalTokens.joined() |
|
|
|
let rawCombined = fullCommitted.isEmpty ? fullNonFinal : (fullCommitted + fullNonFinal) |
|
|
|
let cleaned = SonioxLiveSession.sanitizeText(rawCombined) |
|
|
|
let failureOccurred = hasNetworkFailure |
|
|
|
|
|
|
|
let cb = onFinalResult |
|
|
|
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
|
|
|
@ -329,9 +347,13 @@ public final class SonioxLiveSession { |
|
|
|
lock.unlock() |
|
|
|
|
|
|
|
DispatchQueue.main.async { |
|
|
|
if cleaned.isEmpty && failureOccurred { |
|
|
|
cb?(.failure(NSError(domain: "SonioxLiveSession", code: -101, userInfo: [NSLocalizedDescriptionKey: "WebSocket network failure with empty transcript"]))) |
|
|
|
} else { |
|
|
|
cb?(.success(cleaned)) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
public func cancel() { |
|
|
|
lock.lock() |
|
|
|
@ -353,25 +375,49 @@ public final class SonioxLiveSession { |
|
|
|
completion: @escaping (Result<String, Error>) -> Void |
|
|
|
) { |
|
|
|
guard !pcmData.isEmpty else { |
|
|
|
completion(.success("")) |
|
|
|
completion(.failure(NSError(domain: "SonioxLiveSession", code: -102, userInfo: [NSLocalizedDescriptionKey: "Empty audio buffer for recovery"]))) |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
let effectiveTarget = targetLanguage ?? (SonioxSettings.shared.targetLanguage.isEnabled ? SonioxSettings.shared.targetLanguage.rawValue : nil) |
|
|
|
print("SonioxLiveSession: Starting Smart Burst Retry with \(pcmData.count) bytes (\(Double(pcmData.count)/32000.0)s audio, target: \(effectiveTarget ?? "orig"))...") |
|
|
|
|
|
|
|
// Try Primary endpoint, failover to Fallback if needed |
|
|
|
executeBurstSession(endpoint: primaryWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in |
|
|
|
let durationSec = Double(pcmData.count) / 32000.0 |
|
|
|
print("SonioxLiveSession: Initiating Smart Burst Recovery: \(pcmData.count) bytes (\(String(format: "%.1f", durationSec))s audio, target: \(effectiveTarget ?? "orig"))...") |
|
|
|
|
|
|
|
// Multi-stage resilient retry pipeline across dual endpoints with automatic backoff: |
|
|
|
// Attempt 1: Primary WS |
|
|
|
// Attempt 2: Fallback WS |
|
|
|
// Attempt 3: (pause 0.8s for network interface/VPN to stabilize) Primary WS |
|
|
|
// Attempt 4: Fallback WS |
|
|
|
|
|
|
|
func executeAttempt(_ step: Int) { |
|
|
|
let isPrimary = (step == 1 || step == 3) |
|
|
|
let endpoint = isPrimary ? primaryWsBase : fallbackWsBase |
|
|
|
let name = isPrimary ? "Primary" : "Fallback" |
|
|
|
print("SonioxLiveSession: Smart Recovery Attempt \(step)/4 on \(name) endpoint...") |
|
|
|
|
|
|
|
executeBurstSession(endpoint: endpoint, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress) { result in |
|
|
|
switch result { |
|
|
|
case .success(let text): |
|
|
|
print("SonioxLiveSession: Recovery Attempt \(step) SUCCEEDED (\(text.count) chars).") |
|
|
|
completion(.success(text)) |
|
|
|
case .failure(let error): |
|
|
|
print("SonioxLiveSession: Primary retry failed (\(error)), attempting fallback endpoint...") |
|
|
|
executeBurstSession(endpoint: fallbackWsBase, pcmData: pcmData, languageHints: languageHints, targetLanguage: effectiveTarget, onProgress: onProgress, completion: completion) |
|
|
|
print("SonioxLiveSession: Recovery Attempt \(step) failed: \(error)") |
|
|
|
if step < 4 { |
|
|
|
let delay = (step == 2) ? 0.8 : 0.2 // 0.8s pause before attempt 3 for network interface stabilization |
|
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + delay) { |
|
|
|
executeAttempt(step + 1) |
|
|
|
} |
|
|
|
} else { |
|
|
|
print("SonioxLiveSession: All 4 recovery attempts exhausted: \(error)") |
|
|
|
completion(.failure(error)) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
executeAttempt(1) |
|
|
|
} |
|
|
|
|
|
|
|
private static func executeBurstSession( |
|
|
|
endpoint: String, |
|
|
|
pcmData: Data, |
|
|
|
@ -402,9 +448,14 @@ public final class SonioxLiveSession { |
|
|
|
var request = URLRequest(url: url) |
|
|
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent") |
|
|
|
request.setValue(origin, forHTTPHeaderField: "Origin") |
|
|
|
request.timeoutInterval = 25.0 |
|
|
|
|
|
|
|
let durationSec = Double(pcmData.count) / 32000.0 |
|
|
|
// Dynamic watchdog: allow generous time for network reconnect + fast upload + STT inference |
|
|
|
let watchdogTimeout = max(8.0, min(30.0, durationSec * 0.75 + 6.0)) |
|
|
|
request.timeoutInterval = watchdogTimeout + 5.0 |
|
|
|
|
|
|
|
let config = URLSessionConfiguration.default |
|
|
|
config.waitsForConnectivity = true |
|
|
|
let session = URLSession(configuration: config) |
|
|
|
let task = session.webSocketTask(with: request) |
|
|
|
task.resume() |
|
|
|
@ -414,16 +465,16 @@ public final class SonioxLiveSession { |
|
|
|
var isDone = false |
|
|
|
let lock = NSLock() |
|
|
|
|
|
|
|
func finishOnce(with text: String) { |
|
|
|
func finishOnce(with result: Result<String, Error>) { |
|
|
|
lock.lock() |
|
|
|
if isDone { lock.unlock(); return } |
|
|
|
isDone = true |
|
|
|
task.cancel(with: .normalClosure, reason: nil) |
|
|
|
session.finishTasksAndInvalidate() |
|
|
|
lock.unlock() |
|
|
|
|
|
|
|
let sanitized = sanitizeText(text) |
|
|
|
DispatchQueue.main.async { |
|
|
|
completion(.success(sanitized)) |
|
|
|
completion(result) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -478,16 +529,26 @@ public final class SonioxLiveSession { |
|
|
|
|
|
|
|
let ended = (json["session_ended"] as? Bool ?? false) || ((json["type"] as? String) == "session_done") |
|
|
|
if gotFin || ended { |
|
|
|
finishOnce(with: combined) |
|
|
|
let sanitized = sanitizeText(combined) |
|
|
|
if !sanitized.isEmpty { |
|
|
|
finishOnce(with: .success(sanitized)) |
|
|
|
} else { |
|
|
|
finishOnce(with: .failure(NSError(domain: "Soniox", code: -4, userInfo: [NSLocalizedDescriptionKey: "Server returned empty transcript"]))) |
|
|
|
} |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
receiveNext() |
|
|
|
|
|
|
|
case .failure(let err): |
|
|
|
print("SonioxLiveSession: Burst receive error:", err) |
|
|
|
print("SonioxLiveSession: Burst receive error on \(endpoint):", err) |
|
|
|
let combined = fullCommitted.joined() + fullNonFinal.joined() |
|
|
|
finishOnce(with: combined) |
|
|
|
let sanitized = sanitizeText(combined) |
|
|
|
if !sanitized.isEmpty { |
|
|
|
finishOnce(with: .success(sanitized)) |
|
|
|
} else { |
|
|
|
finishOnce(with: .failure(err)) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -499,6 +560,11 @@ public final class SonioxLiveSession { |
|
|
|
let chunkSize = 16000 // 0.5s audio |
|
|
|
var offset = 0 |
|
|
|
while offset < pcmData.count { |
|
|
|
lock.lock() |
|
|
|
let done = isDone |
|
|
|
lock.unlock() |
|
|
|
if done { break } |
|
|
|
|
|
|
|
let length = min(chunkSize, pcmData.count - offset) |
|
|
|
let chunk = pcmData.subdata(in: offset..<(offset + length)) |
|
|
|
task.send(URLSessionWebSocketTask.Message.data(chunk)) { _ in } |
|
|
|
@ -506,17 +572,29 @@ public final class SonioxLiveSession { |
|
|
|
usleep(8000) // 8ms pace |
|
|
|
} |
|
|
|
|
|
|
|
lock.lock() |
|
|
|
let done = isDone |
|
|
|
lock.unlock() |
|
|
|
if !done { |
|
|
|
// Send trailing silence + finalize |
|
|
|
let silence = Data(count: 3840) |
|
|
|
task.send(URLSessionWebSocketTask.Message.data(silence)) { _ in } |
|
|
|
let finalizeMsg = URLSessionWebSocketTask.Message.string("{\"type\": \"finalize\"}") |
|
|
|
task.send(finalizeMsg) { _ in } |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Watchdog timeout for burst retry: max 2.5 seconds (never hangs UI) |
|
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 2.5) { |
|
|
|
// Dynamic Watchdog timeout |
|
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + watchdogTimeout) { |
|
|
|
let combined = fullCommitted.joined() + fullNonFinal.joined() |
|
|
|
finishOnce(with: combined) |
|
|
|
let sanitized = sanitizeText(combined) |
|
|
|
if !sanitized.isEmpty { |
|
|
|
print("SonioxLiveSession: Burst watchdog fired, salvaging partial text: \(sanitized.prefix(30))...") |
|
|
|
finishOnce(with: .success(sanitized)) |
|
|
|
} else { |
|
|
|
print("SonioxLiveSession: Burst watchdog timed out after \(watchdogTimeout)s with no text.") |
|
|
|
finishOnce(with: .failure(NSError(domain: "Soniox", code: -5, userInfo: [NSLocalizedDescriptionKey: "Burst watchdog timed out after \(watchdogTimeout)s"]))) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|