@ -7,6 +7,15 @@ public final class SonioxLiveSession {
private static let userAgent = " Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 "
private static let userAgent = " Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 "
private static let origin = " https://translate.compare.soniox.com "
private static let origin = " https://translate.compare.soniox.com "
// P r e - c o m p i l e d s t a t i c r e g e x e s f o r z e r o - a l l o c a t i o n h o t p a t h
private static let faRegex : NSRegularExpression = {
return try ! NSRegularExpression ( pattern : " [ \\ u0600- \\ u06FF \\ u0750- \\ u077F \\ uFB50- \\ uFDFF \\ uFE70- \\ uFEFF] " , options : [ ] )
} ( )
private static let enRegex : NSRegularExpression = {
return try ! NSRegularExpression ( pattern : " [a-zA-Z] " , options : [ ] )
} ( )
private static let punctCharSet = CharacterSet ( charactersIn : " .,!?:;،؛؟ \" '()[]{}«»-–— " )
public let targetLanguage : String ?
public let targetLanguage : String ?
public var isTranslationMode : Bool {
public var isTranslationMode : Bool {
guard let target = targetLanguage , ! target . isEmpty , target != " off " else { return false }
guard let target = targetLanguage , ! target . isEmpty , target != " off " else { return false }
@ -18,12 +27,16 @@ public final class SonioxLiveSession {
private var isFinalizing = false
private var isFinalizing = false
private var isClosed = false
private var isClosed = false
private var isConnected = false
private var isConnected = false
private var isAborted = false
private let lock = NSLock ( )
private let lock = NSLock ( )
// T r a n s c r i p t s t r a c k i n g
// T r a n s c r i p t s & S t a l l tr a c k i n g
private var committedFinalTokens : [ String ] = [ ]
private var committedFinalTokens : [ String ] = [ ]
private var currentNonFinalTokens : [ String ] = [ ]
private var currentNonFinalTokens : [ String ] = [ ]
private var streamedPrefix : String = " "
private var streamedPrefix : String = " "
private var sentAudioBytes : Int = 0
public private ( set ) var totalReceivedTokensCount : Int = 0
public private ( set ) var hasSilentStall : Bool = false
// C a l l b a c k s
// C a l l b a c k s
public var onPartialText : ( ( String ) -> Void ) ?
public var onPartialText : ( ( String ) -> Void ) ?
@ -41,13 +54,22 @@ public final class SonioxLiveSession {
public func setRecordingActive ( _ active : Bool ) {
public func setRecordingActive ( _ active : Bool ) {
lock . lock ( )
lock . lock ( )
isRecordingActive = active
isRecordingActive = active
if active {
lastDataTime = Date ( )
}
lock . unlock ( )
lock . unlock ( )
}
}
public var isReady : Bool {
public var isReady : Bool {
lock . lock ( )
lock . lock ( )
defer { lock . unlock ( ) }
defer { lock . unlock ( ) }
return isConnected && ! isClosed && ! isFinalizing
return isConnected && ! isClosed && ! isFinalizing && ! isAborted
}
public var isAlive : Bool {
lock . lock ( )
defer { lock . unlock ( ) }
return ! isClosed && ! isAborted && ! hasNetworkFailure
}
}
public func startKeepAliveHeartbeat ( ) {
public func startKeepAliveHeartbeat ( ) {
@ -57,12 +79,13 @@ public final class SonioxLiveSession {
self . heartbeatTimer = Timer . scheduledTimer ( withTimeInterval : 6.0 , repeats : true ) { [ weak self ] _ in
self . heartbeatTimer = Timer . scheduledTimer ( withTimeInterval : 6.0 , repeats : true ) { [ weak self ] _ in
guard let self = self else { return }
guard let self = self else { return }
self . lock . lock ( )
self . lock . lock ( )
guard ! self . isClosed , ! self . isFinalizing , let task = self . webSocketTask else {
guard ! self . isClosed , ! self . isFinalizing , ! self . isAborted , let task = self . webSocketTask else {
self . lock . unlock ( )
self . lock . unlock ( )
return
return
}
}
self . lock . unlock ( )
self . lock . unlock ( )
let t0 = Date ( )
task . sendPing { [ weak self ] error in
task . sendPing { [ weak self ] error in
guard let self = self else { return }
guard let self = self else { return }
if let error = error {
if let error = error {
@ -73,6 +96,14 @@ public final class SonioxLiveSession {
self . isClosed = true
self . isClosed = true
self . lock . unlock ( )
self . lock . unlock ( )
SonioxSessionPool . shared . prewarmNextSession ( )
SonioxSessionPool . shared . prewarmNextSession ( )
} else {
let rtt = Date ( ) . timeIntervalSince ( t0 )
if rtt > 0.95 {
self . lock . lock ( )
self . hasSilentStall = true
self . lock . unlock ( )
self . onNetworkDegraded ? ( true )
}
}
}
}
}
}
}
@ -112,10 +143,10 @@ public final class SonioxLiveSession {
var request = URLRequest ( url : url )
var request = URLRequest ( url : url )
request . setValue ( SonioxLiveSession . userAgent , forHTTPHeaderField : " User-Agent " )
request . setValue ( SonioxLiveSession . userAgent , forHTTPHeaderField : " User-Agent " )
request . setValue ( SonioxLiveSession . origin , forHTTPHeaderField : " Origin " )
request . setValue ( SonioxLiveSession . origin , forHTTPHeaderField : " Origin " )
request . timeoutInterval = 25 .0
request . timeoutInterval = 1 2.0
let config = URLSessionConfiguration . default
let config = URLSessionConfiguration . default
config . waitsForConnectivity = true
config . waitsForConnectivity = false // F a i l f a s t o n d e a d i n t e r f a c e s o r e c o v e r y / f a i l o v e r t r i g g e r s i m m e d i a t e l y
config . requestCachePolicy = . reloadIgnoringLocalCacheData
config . requestCachePolicy = . reloadIgnoringLocalCacheData
let session = URLSession ( configuration : config )
let session = URLSession ( configuration : config )
@ -128,8 +159,9 @@ public final class SonioxLiveSession {
task . sendPing { [ weak self ] error in
task . sendPing { [ weak self ] error in
guard let self = self else { return }
guard let self = self else { return }
self . lock . lock ( )
self . lock . lock ( )
if error = = nil && ! self . isClosed {
if error = = nil && ! self . isClosed && ! self . isAborted {
self . isConnected = true
self . isConnected = true
self . lastDataTime = Date ( )
self . lock . unlock ( )
self . lock . unlock ( )
self . onConnectionStateChanged ? ( true )
self . onConnectionStateChanged ? ( true )
print ( " SonioxLiveSession: Connected successfully. " )
print ( " SonioxLiveSession: Connected successfully. " )
@ -146,27 +178,59 @@ public final class SonioxLiveSession {
startReceiving ( )
startReceiving ( )
}
}
// / C a l l e d w h i l e u s e r i s a c t i v e l y s p e a k i n g t o d e t e c t h a l f - o p e n T C P s i l e n t s t a l l s
public func checkSilentStallWhileSpeaking ( ) {
lock . lock ( )
let connected = isConnected && ! isClosed && ! isAborted
let sent = sentAudioBytes
let elapsedSinceData = Date ( ) . timeIntervalSince ( lastDataTime )
lock . unlock ( )
// I f > 1 . 5 s o f a u d i o ( 4 8 K B ) w a s s e n t w h i l e s p e a k i n g , b u t 0 f r a m e s a r r i v e d f o r > 3 . 0 s :
if connected && sent > 48000 && elapsedSinceData > 3.0 {
lock . lock ( )
hasSilentStall = true
lock . unlock ( )
DispatchQueue . main . async { [ weak self ] in
self ? . onNetworkDegraded ? ( true )
}
}
}
public func sendAudioChunk ( _ data : Data ) {
public func sendAudioChunk ( _ data : Data ) {
lock . lock ( )
lock . lock ( )
defer { lock . unlock ( ) }
guard ! isFinalizing , ! isClosed , let task = webSocketTask else { return }
guard ! isFinalizing , ! isClosed , ! isAborted , let task = webSocketTask else {
lock . unlock ( )
return
}
lock . unlock ( )
let t0 = Date ( )
let message = URLSessionWebSocketTask . Message . data ( data )
let message = URLSessionWebSocketTask . Message . data ( data )
task . send ( message ) { [ weak self ] error in
task . send ( message ) { [ weak self ] error in
guard let self = self else { return }
if let error = error {
if let error = error {
print ( " SonioxLiveSession: Error streaming audio chunk: " , error )
print ( " SonioxLiveSession: Error streaming audio chunk: " , error )
self ? . lock . lock ( )
self ? . hasNetworkFailure = true
self ? . isConnected = false
self ? . lock . unlock ( )
self ? . onNetworkDegraded ? ( true )
self . lock . lock ( )
self . hasNetworkFailure = true
self . isConnected = false
self . lock . unlock ( )
self . onNetworkDegraded ? ( true )
} else {
self . lock . lock ( )
self . sentAudioBytes += data . count
let sendDur = Date ( ) . timeIntervalSince ( t0 )
if sendDur > 0.70 {
self . hasSilentStall = true
}
self . lock . unlock ( )
}
}
}
}
}
}
public func finalizeStream ( ) {
public func finalizeStream ( ) {
lock . lock ( )
lock . lock ( )
guard ! isFinalizing , ! isClosed , let task = webSocketTask else {
guard ! isFinalizing , ! isClosed , ! isAborted , let task = webSocketTask else {
lock . unlock ( )
lock . unlock ( )
return
return
}
}
@ -175,7 +239,6 @@ public final class SonioxLiveSession {
print ( " SonioxLiveSession: Sending finalize packet... " )
print ( " SonioxLiveSession: Sending finalize packet... " )
// 1 . S e n d t r a i l i n g 3 0 0 m s ( 9 6 0 0 b y t e s a t 1 6 k H z 1 6 - b i t P C M ) s i l e n c e f r a m e
// 1 . S e n d t r a i l i n g 3 0 0 m s ( 9 6 0 0 b y t e s a t 1 6 k H z 1 6 - b i t P C M ) s i l e n c e f r a m e
// s o a c o u s t i c C T C m o d e l c l e a n l y f l u s h e s a n d r e s o l v e s t r a i l i n g f a s t s p e e c h s y l l a b l e s
let silenceChunk = Data ( count : 9600 )
let silenceChunk = Data ( count : 9600 )
let silenceMsg = URLSessionWebSocketTask . Message . data ( silenceChunk )
let silenceMsg = URLSessionWebSocketTask . Message . data ( silenceChunk )
task . send ( silenceMsg ) { _ in }
task . send ( silenceMsg ) { _ in }
@ -192,14 +255,14 @@ public final class SonioxLiveSession {
}
}
}
}
// S a f e t y t i m e o u t f a l l b a c k : a l l o w u p t o 4 . 5 s f o r S T T i n f e r e n c e & n e t w o r k t r a n s i t
DispatchQueue . global ( ) . asyncAfter ( deadline : . now ( ) + 4.5 ) { [ weak self ] in
// S a f e t y t i m e o u t f a l l b a c k : a l l o w u p t o 4 . 0 s f o r S T T i n f e r e n c e & n e t w o r k t r a n s i t
DispatchQueue . global ( ) . asyncAfter ( deadline : . now ( ) + 4.0 ) { [ weak self ] in
guard let self = self else { return }
guard let self = self else { return }
self . lock . lock ( )
self . lock . lock ( )
let stillOpen = ! self . isClosed
let stillOpen = ! self . isClosed && ! self . isAborted
self . lock . unlock ( )
self . lock . unlock ( )
if stillOpen {
if stillOpen {
print ( " SonioxLiveSession: Finalize timeout (4.5 s) reached — completing. " )
print ( " SonioxLiveSession: Finalize timeout (4.0 s) reached — completing. " )
self . completeWithCurrentText ( )
self . completeWithCurrentText ( )
}
}
}
}
@ -211,7 +274,7 @@ public final class SonioxLiveSession {
guard let self = self else { return }
guard let self = self else { return }
self . lock . lock ( )
self . lock . lock ( )
if self . isClosed {
if self . isClosed || self . isAborted {
self . lock . unlock ( )
self . lock . unlock ( )
return
return
}
}
@ -219,7 +282,12 @@ public final class SonioxLiveSession {
switch result {
switch result {
case . success ( let message ) :
case . success ( let message ) :
self . lock . lock ( )
self . lastDataTime = Date ( )
self . lastDataTime = Date ( )
self . hasSilentStall = false
self . lock . unlock ( )
self . onNetworkDegraded ? ( false )
var textReceived : String ?
var textReceived : String ?
switch message {
switch message {
case . string ( let str ) :
case . string ( let str ) :
@ -244,8 +312,6 @@ public final class SonioxLiveSession {
self . lock . unlock ( )
self . lock . unlock ( )
self . onNetworkDegraded ? ( true )
self . onNetworkDegraded ? ( true )
// I f r e c o r d i n g i s s t i l l a c t i v e ( u s e r s p e a k i n g ) , k e e p b u f f e r i n g i n A u d i o R e c o r d e r ;
// d o N O T c o m p l e t e o r d e l i v e r e m p t y t e x t p r e m a t u r e l y !
if ! activeRecording {
if ! activeRecording {
self . completeWithCurrentText ( )
self . completeWithCurrentText ( )
} else {
} else {
@ -261,19 +327,16 @@ public final class SonioxLiveSession {
var gotFin = false
var gotFin = false
var newFinals : [ String ] = [ ]
var newFinals : [ String ] = [ ]
var newNonFinals : [ String ] = [ ]
var newNonFinals : [ String ] = [ ]
var tokenCountDelta = 0
if let type = json [ " type " ] as ? String , type = = " data " ,
if let type = json [ " type " ] as ? String , type = = " data " ,
let parts = json [ " parts " ] as ? [ [ String : Any ] ] {
let parts = json [ " parts " ] as ? [ [ String : Any ] ] {
for part in parts {
for part in parts {
let isTranslation = ( part [ " translation_status " ] as ? String ) = = " translation "
let isTranslation = ( part [ " translation_status " ] as ? String ) = = " translation "
if self . isTranslationMode {
if self . isTranslationMode {
if ! isTranslation {
continue
}
if ! isTranslation { continue }
} else {
} else {
if isTranslation {
continue
}
if isTranslation { continue }
}
}
let pText = part [ " text " ] as ? String ? ? " "
let pText = part [ " text " ] as ? String ? ? " "
@ -284,19 +347,27 @@ public final class SonioxLiveSession {
let clean = pText . replacingOccurrences ( of : " <fin> " , with : " " )
let clean = pText . replacingOccurrences ( of : " <fin> " , with : " " )
if ! clean . isEmpty {
if ! clean . isEmpty {
newFinals . append ( clean )
newFinals . append ( clean )
tokenCountDelta += 1
}
}
} else if isFinal {
} else if isFinal {
if ! pText . isEmpty {
if ! pText . isEmpty {
newFinals . append ( pText )
newFinals . append ( pText )
tokenCountDelta += 1
}
}
} else {
} else {
if ! pText . isEmpty {
if ! pText . isEmpty {
newNonFinals . append ( pText )
newNonFinals . append ( pText )
tokenCountDelta += 1
}
}
}
}
}
}
lock . lock ( )
lock . lock ( )
if isAborted {
lock . unlock ( )
return
}
totalReceivedTokensCount += tokenCountDelta
if ! newFinals . isEmpty {
if ! newFinals . isEmpty {
committedFinalTokens . append ( contentsOf : newFinals )
committedFinalTokens . append ( contentsOf : newFinals )
}
}
@ -311,7 +382,7 @@ public final class SonioxLiveSession {
let trimmedCombined = combined . trimmingCharacters ( in : . whitespacesAndNewlines )
let trimmedCombined = combined . trimmingCharacters ( in : . whitespacesAndNewlines )
let currentWords = trimmedCombined . components ( separatedBy : . whitespacesAndNewlines ) . filter { ! $0 . isEmpty }
let currentWords = trimmedCombined . components ( separatedBy : . whitespacesAndNewlines ) . filter { ! $0 . isEmpty }
if let lastRaw = currentWords . last {
if let lastRaw = currentWords . last {
let cleanLast = lastRaw . replacingOccurrences ( of : # " [.,!?:;،؛؟ " ' ( ) \ [ \ ] { } « » – — \ - ] " #, with: " " , options: .regularExpression).lowercased( )
let cleanLast = InputBoxManager . stripPunctuation ( lastRaw ) . lowercased ( )
if cleanLast = = " and " || cleanLast = = " تمام " || cleanLast = = " end " {
if cleanLast = = " and " || cleanLast = = " تمام " || cleanLast = = " end " {
triggerDetected = true
triggerDetected = true
}
}
@ -360,9 +431,18 @@ public final class SonioxLiveSession {
}
}
}
}
public func currentBestTranscript ( ) -> String {
lock . lock ( )
let fullCommitted = committedFinalTokens . joined ( )
let fullNonFinal = currentNonFinalTokens . joined ( )
let rawCombined = fullCommitted . isEmpty ? fullNonFinal : ( fullCommitted + fullNonFinal )
lock . unlock ( )
return SonioxLiveSession . sanitizeText ( rawCombined )
}
public func completeWithCurrentText ( ) {
public func completeWithCurrentText ( ) {
lock . lock ( )
lock . lock ( )
if isClosed {
if isClosed || isAborted {
lock . unlock ( )
lock . unlock ( )
return
return
}
}
@ -376,9 +456,12 @@ public final class SonioxLiveSession {
let cb = onFinalResult
let cb = onFinalResult
webSocketTask ? . cancel ( with : . normalClosure , reason : nil )
webSocketTask ? . cancel ( with : . normalClosure , reason : nil )
webSocketTask = nil
webSocketTask = nil
urlSession ? . finishTasksAndInvalidate ( )
urlSession = nil
urlSession = nil
lock . unlock ( )
lock . unlock ( )
stopKeepAliveHeartbeat ( )
DispatchQueue . main . async {
DispatchQueue . main . async {
if cleaned . isEmpty && failureOccurred {
if cleaned . isEmpty && failureOccurred {
cb ? ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 101 , userInfo : [ NSLocalizedDescriptionKey : " WebSocket network failure with empty transcript " ] ) ) )
cb ? ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 101 , userInfo : [ NSLocalizedDescriptionKey : " WebSocket network failure with empty transcript " ] ) ) )
@ -388,13 +471,33 @@ public final class SonioxLiveSession {
}
}
}
}
public func cancel ( ) {
// / I m m e d i a t e h a r d c a n c e l l a t i o n ( E s c a p e k e y p r e s s e d ) — s u p p r e s s e s a l l f u r t h e r c a l l b a c k s
public func abort ( ) {
lock . lock ( )
lock . lock ( )
isAborted = true
isClosed = true
isClosed = true
webSocketTask ? . cancel ( with : . normalClosure , reason : nil )
isConnected = false
isRecordingActive = false
isFinalizing = false
onPartialText = nil
onCommittedDelta = nil
onEndTriggerDetected = nil
onFinalResult = nil
onConnectionStateChanged = nil
onNetworkDegraded = nil
let task = webSocketTask
let session = urlSession
webSocketTask = nil
webSocketTask = nil
urlSession = nil
urlSession = nil
lock . unlock ( )
lock . unlock ( )
stopKeepAliveHeartbeat ( )
task ? . cancel ( )
session ? . invalidateAndCancel ( )
}
public func cancel ( ) {
abort ( )
}
}
// MARK: - S m a r t A u d i o R e c o v e r y / F a s t B u r s t R e t r y
// MARK: - S m a r t A u d i o R e c o v e r y / F a s t B u r s t R e t r y
@ -402,13 +505,15 @@ public final class SonioxLiveSession {
// / R e - t r a n s c r i b e s t h e f u l l a u d i o b u f f e r w h e n t h e l i v e s t r e a m h a d p a c k e t d r o p s , d i s c o n n e c t s , o r m i s s i n g t r a i l i n g w o r d s .
// / R e - t r a n s c r i b e s t h e f u l l a u d i o b u f f e r w h e n t h e l i v e s t r e a m h a d p a c k e t d r o p s , d i s c o n n e c t s , o r m i s s i n g t r a i l i n g w o r d s .
public static func retryFullBufferedAudio (
public static func retryFullBufferedAudio (
pcmData : Data ,
pcmData : Data ,
languageHints : [ String ] ,
languageHints : [ String ] = SonioxSettings . shared . language . hints ,
targetLanguage : String ? = nil ,
targetLanguage : String ? = nil ,
isCancelled : @ escaping ( ) -> Bool = { false } ,
onProgress : ( ( String ) -> Void ) ? = nil ,
onProgress : ( ( String ) -> Void ) ? = nil ,
onStatusUpdate : ( ( String ) -> Void ) ? = nil ,
completion : @ escaping ( Result < String , Error > ) -> Void
completion : @ escaping ( Result < String , Error > ) -> Void
) {
) {
guard ! pcmData . isEmpty else {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 102 , userInfo : [ NSLocalizedDescriptionKey : " Empty audio buffer for recovery " ] ) ) )
guard ! pcmData . isEmpty , ! isCancelled ( ) else {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 102 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled or empty audio buffer " ] ) ) )
return
return
}
}
@ -423,25 +528,62 @@ public final class SonioxLiveSession {
// A t t e m p t 4 : F a l l b a c k W S
// A t t e m p t 4 : F a l l b a c k W S
func executeAttempt ( _ step : Int ) {
func executeAttempt ( _ step : Int ) {
if isCancelled ( ) {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 999 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled by Escape " ] ) ) )
return
}
let isPrimary = ( step = = 1 || step = = 3 )
let isPrimary = ( step = = 1 || step = = 3 )
let endpoint = isPrimary ? primaryWsBase : fallbackWsBase
let endpoint = isPrimary ? primaryWsBase : fallbackWsBase
let name = isPrimary ? " Primary " : " Fallback "
let name = isPrimary ? " Primary " : " Fallback "
print ( " SonioxLiveSession: Smart Recovery Attempt \( step ) /4 on \( name ) endpoint... " )
print ( " SonioxLiveSession: Smart Recovery Attempt \( step ) /4 on \( name ) endpoint... " )
executeBurstSession ( endpoint : endpoint , pcmData : pcmData , languageHints : languageHints , targetLanguage : effectiveTarget , onProgress : onProgress ) { result in
if step > 1 {
DispatchQueue . main . async {
onStatusUpdate ? ( " RETRY \( step ) /4 " )
}
}
// I f n e t w o r k i n t e r f a c e i s c o m p l e t e l y o f f l i n e o n a t t e m p t > = 2 , w a i t b r i e f l y f o r W i - F i / V P N h a n d o f f
if ! PingMonitor . shared . isPathSatisfied && step >= 2 {
DispatchQueue . global ( qos : . userInitiated ) . asyncAfter ( deadline : . now ( ) + 1.0 ) {
if isCancelled ( ) {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 999 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled " ] ) ) )
return
}
if step < 4 {
executeAttempt ( step + 1 )
} else {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 1009 , userInfo : [ NSLocalizedDescriptionKey : " Network offline " ] ) ) )
}
}
return
}
executeBurstSession (
endpoint : endpoint ,
pcmData : pcmData ,
languageHints : languageHints ,
targetLanguage : effectiveTarget ,
isCancelled : isCancelled ,
onProgress : onProgress
) { result in
if isCancelled ( ) {
completion ( . failure ( NSError ( domain : " SonioxLiveSession " , code : - 999 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled " ] ) ) )
return
}
switch result {
switch result {
case . success ( let text ) :
case . success ( let text ) :
print ( " SonioxLiveSession: Recovery Attempt \( step ) SUCCEEDED ( \( text . count ) chars). " )
print ( " SonioxLiveSession: ✅ Recovery Attempt \( step ) SUCCEEDED ( \( text . count ) chars). " )
completion ( . success ( text ) )
completion ( . success ( text ) )
case . failure ( let error ) :
case . failure ( let error ) :
print ( " SonioxLiveSession: Recovery Attempt \( step ) failed: \( error ) " )
print ( " SonioxLiveSession: Recovery Attempt \( step ) failed: \( error ) " )
if step < 4 {
if step < 4 {
let delay = ( step = = 2 ) ? 0.8 : 0.2 // 0 . 8 s p a u s e b e f o r e a t t e m p t 3 f o r n e t w o r k i n t e r f a c e s t a b i l i z a t i o n
DispatchQueue . global ( ) . asyncAfter ( deadline : . now ( ) + delay ) {
let delay = ( step = = 2 ) ? 0.8 : 0.25
DispatchQueue . global ( qos : . userInitiated ) . asyncAfter ( deadline : . now ( ) + delay ) {
executeAttempt ( step + 1 )
executeAttempt ( step + 1 )
}
}
} else {
} else {
print ( " SonioxLiveSession: All 4 recovery attempts exhausted: \( error ) " )
print ( " SonioxLiveSession: ❌ All 4 recovery attempts exhausted: \( error ) " )
completion ( . failure ( error ) )
completion ( . failure ( error ) )
}
}
}
}
@ -456,6 +598,7 @@ public final class SonioxLiveSession {
pcmData : Data ,
pcmData : Data ,
languageHints : [ String ] ,
languageHints : [ String ] ,
targetLanguage : String ? ,
targetLanguage : String ? ,
isCancelled : @ escaping ( ) -> Bool ,
onProgress : ( ( String ) -> Void ) ? ,
onProgress : ( ( String ) -> Void ) ? ,
completion : @ escaping ( Result < String , Error > ) -> Void
completion : @ escaping ( Result < String , Error > ) -> Void
) {
) {
@ -483,12 +626,14 @@ public final class SonioxLiveSession {
request . setValue ( origin , forHTTPHeaderField : " Origin " )
request . setValue ( origin , forHTTPHeaderField : " Origin " )
let durationSec = Double ( pcmData . count ) / 32000.0
let durationSec = Double ( pcmData . count ) / 32000.0
// D y n a m i c w a t c h d o g : a l l o w g e n e r o u s t i m e f o r n e t w o r k r e c o n n e c t + f a s t u p l o a d + S T T i n f e r e n c e
let watchdogTimeout = max ( 8.0 , min ( 30.0 , durationSec * 0.75 + 6.0 ) )
request . timeoutInterval = watchdogTimeout + 5.0
let watchdogTimeout = max ( 6.5 , min ( 25.0 , durationSec * 0.60 + 5.0 ) )
request . timeoutInterval = 5.0 // F a s t h a n d s h a k e t i m e o u t s o d e a d e n d p o i n t f a i l s o v e r i n < 5 s
let config = URLSessionConfiguration . ephemeral
config . timeoutIntervalForRequest = watchdogTimeout
config . timeoutIntervalForResource = watchdogTimeout + 3.0
config . waitsForConnectivity = false // F a i l f a s t o n d e a d i n t e r f a c e s o n e x t e n d p o i n t a t t e m p t r u n s i m m e d i a t e l y !
let config = URLSessionConfiguration . default
config . waitsForConnectivity = true
let session = URLSession ( configuration : config )
let session = URLSession ( configuration : config )
let task = session . webSocketTask ( with : request )
let task = session . webSocketTask ( with : request )
task . resume ( )
task . resume ( )
@ -511,11 +656,18 @@ public final class SonioxLiveSession {
}
}
}
}
// R e c e i v e h a n d l e r
func receiveNext ( ) {
func receiveNext ( ) {
if isCancelled ( ) {
finishOnce ( with : . failure ( NSError ( domain : " Soniox " , code : - 999 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled " ] ) ) )
return
}
task . receive { result in
task . receive { result in
lock . lock ( )
lock . lock ( )
if isDone { lock . unlock ( ) ; return }
if isDone || isCancelled ( ) {
lock . unlock ( )
finishOnce ( with : . failure ( NSError ( domain : " Soniox " , code : - 999 , userInfo : [ NSLocalizedDescriptionKey : " Cancelled " ] ) ) )
return
}
lock . unlock ( )
lock . unlock ( )
switch result {
switch result {
@ -532,6 +684,7 @@ public final class SonioxLiveSession {
var gotFin = false
var gotFin = false
var packetNonFinal : [ String ] = [ ]
var packetNonFinal : [ String ] = [ ]
var packetCommitted : [ String ] = [ ]
if let parts = json [ " parts " ] as ? [ [ String : Any ] ] {
if let parts = json [ " parts " ] as ? [ [ String : Any ] ] {
for p in parts {
for p in parts {
let isTranslation = ( p [ " translation_status " ] as ? String ) = = " translation "
let isTranslation = ( p [ " translation_status " ] as ? String ) = = " translation "
@ -545,9 +698,9 @@ public final class SonioxLiveSession {
if t . contains ( " <fin> " ) {
if t . contains ( " <fin> " ) {
gotFin = true
gotFin = true
let clean = t . replacingOccurrences ( of : " <fin> " , with : " " )
let clean = t . replacingOccurrences ( of : " <fin> " , with : " " )
if ! clean . isEmpty { full Committed. append ( clean ) }
if ! clean . isEmpty { packet Committed. append ( clean ) }
} else if isFinal {
} else if isFinal {
if ! t . isEmpty { full Committed. append ( t ) }
if ! t . isEmpty { packet Committed. append ( t ) }
} else {
} else {
if ! t . isEmpty { packetNonFinal . append ( t ) }
if ! t . isEmpty { packetNonFinal . append ( t ) }
}
}
@ -555,6 +708,9 @@ public final class SonioxLiveSession {
}
}
lock . lock ( )
lock . lock ( )
if ! packetCommitted . isEmpty {
fullCommitted . append ( contentsOf : packetCommitted )
}
latestNonFinal = packetNonFinal
latestNonFinal = packetNonFinal
let currentProgress = fullCommitted . joined ( ) + ( gotFin ? " " : latestNonFinal . joined ( ) )
let currentProgress = fullCommitted . joined ( ) + ( gotFin ? " " : latestNonFinal . joined ( ) )
lock . unlock ( )
lock . unlock ( )
@ -598,12 +754,15 @@ public final class SonioxLiveSession {
receiveNext ( )
receiveNext ( )
// H a n d s h a k e v e r i f i c a t i o n b e f o r e p u m p i n g a u d i o t o a v o i d e a r l y d r o p s & e m p t y t r a n s c r i p t s
// H a n d s h a k e v e r i f i c a t i o n + C h a i n e d B a c k p r e s s u r e U p l o a d ( 3 2 K B / 1 . 0 s c h u n k s )
task . sendPing { pingError in
task . sendPing { pingError in
lock . lock ( )
lock . lock ( )
let alreadyDone = isDone
let alreadyDone = isDone
lock . unlock ( )
lock . unlock ( )
if alreadyDone { return }
if alreadyDone || isCancelled ( ) {
finishOnce ( with : . failure ( NSError ( domain : " Soniox " , code : - 999 , userInfo : nil ) ) )
return
}
if let pingErr = pingError {
if let pingErr = pingError {
print ( " SonioxLiveSession: Burst connection ping failed on \( endpoint ) : \( pingErr ) " )
print ( " SonioxLiveSession: Burst connection ping failed on \( endpoint ) : \( pingErr ) " )
@ -611,34 +770,33 @@ public final class SonioxLiveSession {
return
return
}
}
// C o n n e c t i o n i s a c t i v e : s t r e a m a u d i o i n h i g h - s p e e d p a c e d b u r s t s
DispatchQueue . global ( qos : . userInitiated ) . async {
let chunkSize = 16000 // 0 . 5 s a u d i o
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 }
offset += length
usleep ( 12000 ) // 1 2 m s p a c e
}
let chunkSize = 32000 // 1 . 0 s o f 1 6 k H z 1 6 - b i t P C M
func sendNextChunk ( offset : Int ) {
lock . lock ( )
lock . lock ( )
let done = isDone
let done = isDone
lock . unlock ( )
lock . unlock ( )
if ! done {
// S e n d t r a i l i n g s i l e n c e + f i n a l i z e
let silence = Data ( count : 4800 )
task . send ( URLSessionWebSocketTask . Message . data ( silence ) ) { _ in }
let finalizeMsg = URLSessionWebSocketTask . Message . string ( " { \" type \" : \" finalize \" } " )
task . send ( finalizeMsg ) { _ in }
if done || isCancelled ( ) { return }
if offset >= pcmData . count {
let silence = Data ( count : 6400 )
task . send ( . data ( silence ) ) { _ in
task . send ( . string ( " { \" type \" : \" finalize \" } " ) ) { _ in }
}
return
}
let end = min ( offset + chunkSize , pcmData . count )
let slice = pcmData . subdata ( in : offset . . < end )
task . send ( . data ( slice ) ) { sendErr in
if let sendErr = sendErr {
finishOnce ( with : . failure ( sendErr ) )
return
}
sendNextChunk ( offset : end )
}
}
}
}
sendNextChunk ( offset : 0 )
}
}
// D y n a m i c W a t c h d o g t i m e o u t
// D y n a m i c W a t c h d o g t i m e o u t
@ -657,7 +815,7 @@ public final class SonioxLiveSession {
}
}
}
}
// MARK: - S a n i t i z a t i o n
// MARK: - S a n i t i z a t i o n ( P r e - c o m p i l e d R e g e x e s )
public static func sanitizeText ( _ text : String ) -> String {
public static func sanitizeText ( _ text : String ) -> String {
let trimmed = text . trimmingCharacters ( in : . whitespacesAndNewlines )
let trimmed = text . trimmingCharacters ( in : . whitespacesAndNewlines )
@ -666,18 +824,16 @@ public final class SonioxLiveSession {
let words = trimmed . components ( separatedBy : . whitespacesAndNewlines ) . filter { ! $0 . isEmpty }
let words = trimmed . components ( separatedBy : . whitespacesAndNewlines ) . filter { ! $0 . isEmpty }
if words . isEmpty { return " " }
if words . isEmpty { return " " }
let faPattern = " [ \\ u0600- \\ u06FF \\ u0750- \\ u077F \\ uFB50- \\ uFDFF \\ uFE70- \\ uFEFF] "
let enPattern = " [a-zA-Z] "
func matches ( _ pattern : String , in str : String ) -> Bool {
return str . range ( of : pattern , options : . regularExpression ) != nil
func matches ( _ regex : NSRegularExpression , in str : String ) -> Bool {
let range = NSRange ( location : 0 , length : str . utf16 . count )
return regex . firstMatch ( in : str , options : [ ] , range : range ) != nil
}
}
var faCount = 0
var faCount = 0
var enCount = 0
var enCount = 0
for w in words {
for w in words {
if matches ( faPattern , in : w ) { faCount += 1 }
if matches ( enPattern , in : w ) { enCount += 1 }
if matches ( faRegex , in : w ) { faCount += 1 }
if matches ( enRegex , in : w ) { enCount += 1 }
}
}
let total = faCount + enCount
let total = faCount + enCount
@ -696,8 +852,8 @@ public final class SonioxLiveSession {
if faRatio >= 0.25 {
if faRatio >= 0.25 {
for w in words {
for w in words {
if matches ( enPattern , in : w ) && ! matches ( faPattern , in : w ) {
let cleanW = w . lowercased ( ) . trimmingCharacters ( in : CharacterSet ( charactersIn : " .,!?:;،؛؟ \" '()[]{}«»-–— " ) )
if matches ( enRegex , in : w ) && ! matches ( faRegex , in : w ) {
let cleanW = w . lowercased ( ) . trimmingCharacters ( in : punctCharSet )
if stopWords . contains ( cleanW ) { continue }
if stopWords . contains ( cleanW ) { continue }
if faRatio >= 0.70 && ! preserveWords . contains ( cleanW ) { continue }
if faRatio >= 0.70 && ! preserveWords . contains ( cleanW ) { continue }
}
}
@ -744,13 +900,14 @@ public final class SonioxSessionPool {
public func discardPrewarmedSession ( ) {
public func discardPrewarmedSession ( ) {
lock . lock ( )
lock . lock ( )
prewarmedSession ? . stopKeepAliveHeartbeat ( )
prewarmedSession ? . stopKeepAliveHeartbeat ( )
prewarmedSession ? . cancel ( )
prewarmedSession ? . abort ( )
prewarmedSession = nil
prewarmedSession = nil
isPrewarming = false
isPrewarming = false
lock . unlock ( )
lock . unlock ( )
}
}
public func prewarmNextSession ( ) {
public func prewarmNextSession ( ) {
guard PingMonitor . shared . isPathSatisfied else { return }
lock . lock ( )
lock . lock ( )
defer { lock . unlock ( ) }
defer { lock . unlock ( ) }
@ -765,7 +922,7 @@ public final class SonioxSessionPool {
isPrewarming = true
isPrewarming = true
prewarmedSession ? . stopKeepAliveHeartbeat ( )
prewarmedSession ? . stopKeepAliveHeartbeat ( )
prewarmedSession ? . cancel ( )
prewarmedSession ? . abort ( )
prewarmedSession = nil
prewarmedSession = nil
DispatchQueue . global ( qos : . userInitiated ) . async { [ weak self ] in
DispatchQueue . global ( qos : . userInitiated ) . async { [ weak self ] in
@ -789,8 +946,7 @@ public final class SonioxSessionPool {
}
}
}
}
// W a t c h d o g i n c a s e c o n n e c t i o n h a n g s i n d e f i n i t e l y
DispatchQueue . global ( ) . asyncAfter ( deadline : . now ( ) + 15.0 ) { [ weak self ] in
DispatchQueue . global ( ) . asyncAfter ( deadline : . now ( ) + 12.0 ) { [ weak self ] in
guard let self = self else { return }
guard let self = self else { return }
self . lock . lock ( )
self . lock . lock ( )
if self . isPrewarming && self . prewarmedSession = = nil {
if self . isPrewarming && self . prewarmedSession = = nil {
@ -813,20 +969,19 @@ public final class SonioxSessionPool {
prewarmedSession = nil
prewarmedSession = nil
lock . unlock ( )
lock . unlock ( )
// I m m e d i a t e l y s t a r t p r e - w a r m i n g t h e N E X T s e s s i o n i n t h e b a c k g r o u n d
DispatchQueue . global ( qos : . userInitiated ) . async { [ weak self ] in
DispatchQueue . global ( qos : . userInitiated ) . async { [ weak self ] in
self ? . prewarmNextSession ( )
self ? . prewarmNextSession ( )
}
}
let age = Date ( ) . timeIntervalSince ( createdAt )
let age = Date ( ) . timeIntervalSince ( createdAt )
if let session = session , session . isReady && age < 60 .0 {
if let session = session , session . isReady && age < 55 .0 {
session . stopKeepAliveHeartbeat ( )
session . stopKeepAliveHeartbeat ( )
print ( " SonioxSessionPool: Acquired HOT prewarmed session (age: \( String ( format : " %.1f " , age ) ) s, 0ms connect delay!) " )
print ( " SonioxSessionPool: Acquired HOT prewarmed session (age: \( String ( format : " %.1f " , age ) ) s, 0ms connect delay!) " )
return session
return session
}
}
session ? . stopKeepAliveHeartbeat ( )
session ? . stopKeepAliveHeartbeat ( )
session ? . cancel ( )
session ? . abort ( )
print ( " SonioxSessionPool: Prewarmed session not ready/stale (age: \( String ( format : " %.1f " , age ) ) s), creating immediate on-demand session. " )
print ( " SonioxSessionPool: Prewarmed session not ready/stale (age: \( String ( format : " %.1f " , age ) ) s), creating immediate on-demand session. " )
let hints = SonioxSettings . shared . language . hints
let hints = SonioxSettings . shared . language . hints
@ -834,3 +989,123 @@ public final class SonioxSessionPool {
return SonioxLiveSession ( languageHints : hints , targetLanguage : target )
return SonioxLiveSession ( languageHints : hints , targetLanguage : target )
}
}
}
}
// MARK: - P e r s i s t e n t O f f l i n e A u d i o Q u e u e ( Z e r o D a t a L o s s o n M u l t i - M i n u t e I n t e r n e t O u t a g e s )
public final class OfflineAudioQueueManager {
public static let shared = OfflineAudioQueueManager ( )
private let queueDir : URL
private let workerQueue = DispatchQueue ( label : " com.soniox.offlinequeue " , qos : . utility )
private var isProcessing = false
private let lock = NSLock ( )
public var onOfflineAudioRecovered : ( ( String ) -> Void ) ?
private init ( ) {
let appSupport = FileManager . default . urls ( for : . applicationSupportDirectory , in : . userDomainMask ) . first !
self . queueDir = appSupport . appendingPathComponent ( " SonioxVoice/PendingAudioQueue " )
try ? FileManager . default . createDirectory ( at : queueDir , withIntermediateDirectories : true )
}
public func enqueueFailedAudio ( pcmData : Data ) {
guard pcmData . count >= 16000 else { return } // O n l y s a v e m e a n i n g f u l s p e e c h ( > = 0 . 5 s )
workerQueue . async { [ weak self ] in
guard let self = self else { return }
try ? FileManager . default . createDirectory ( at : self . queueDir , withIntermediateDirectories : true )
let timestamp = Int ( Date ( ) . timeIntervalSince1970 * 1000 )
let fileURL = self . queueDir . appendingPathComponent ( " speech_ \( timestamp ) .pcm " )
do {
try pcmData . write ( to : fileURL , options : . atomic )
print ( " OfflineAudioQueueManager: 💾 Saved \( pcmData . count ) bytes of speech to disk queue ( \( fileURL . lastPathComponent ) ) for automatic recovery when internet returns. " )
} catch {
print ( " OfflineAudioQueueManager: Failed to write offline audio: " , error )
}
}
}
public func clearAllPending ( ) {
workerQueue . async { [ weak self ] in
guard let self = self else { return }
if let files = try ? FileManager . default . contentsOfDirectory ( at : self . queueDir , includingPropertiesForKeys : nil ) {
for f in files where f . pathExtension = = " pcm " {
try ? FileManager . default . removeItem ( at : f )
}
}
}
}
public func processPendingQueueIfNeeded ( isCancelled : @ escaping ( ) -> Bool = { false } ) {
guard PingMonitor . shared . isPathSatisfied else { return }
lock . lock ( )
if isProcessing {
lock . unlock ( )
return
}
isProcessing = true
lock . unlock ( )
workerQueue . async { [ weak self ] in
guard let self = self else { return }
guard let files = try ? FileManager . default . contentsOfDirectory ( at : self . queueDir , includingPropertiesForKeys : [ . creationDateKey ] ) ,
! files . isEmpty else {
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
return
}
let pcmFiles = files . filter { $0 . pathExtension = = " pcm " } . sorted { $0 . lastPathComponent < $1 . lastPathComponent }
guard let firstFile = pcmFiles . first else {
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
return
}
// D r o p s t a l e f i l e s o l d e r t h a n 2 h o u r s
if let attrs = try ? FileManager . default . attributesOfItem ( atPath : firstFile . path ) ,
let created = attrs [ . creationDate ] as ? Date ,
Date ( ) . timeIntervalSince ( created ) > 7200 {
try ? FileManager . default . removeItem ( at : firstFile )
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
self . processPendingQueueIfNeeded ( isCancelled : isCancelled )
return
}
guard let pcmData = try ? Data ( contentsOf : firstFile ) , ! pcmData . isEmpty else {
try ? FileManager . default . removeItem ( at : firstFile )
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
return
}
print ( " OfflineAudioQueueManager: 🌐 Network is back! Recovering queued speech file \( firstFile . lastPathComponent ) ( \( pcmData . count ) bytes)... " )
SonioxLiveSession . retryFullBufferedAudio ( pcmData : pcmData , isCancelled : isCancelled ) { [ weak self ] result in
guard let self = self else { return }
switch result {
case . success ( let recoveredText ) :
let trimmed = recoveredText . trimmingCharacters ( in : . whitespacesAndNewlines )
if ! trimmed . isEmpty {
try ? FileManager . default . removeItem ( at : firstFile )
DispatchQueue . main . async {
self . onOfflineAudioRecovered ? ( trimmed )
}
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
self . processPendingQueueIfNeeded ( isCancelled : isCancelled )
return
}
case . failure :
break
}
self . lock . lock ( )
self . isProcessing = false
self . lock . unlock ( )
}
}
}
}