Add iOS client for WhisperLive (Audio-Transcription-iOS)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
//
|
||||
// RecordingViewModel.swift
|
||||
// Lecture2Quiz
|
||||
//
|
||||
// Created by ParkMazorika on 4/27/25.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
/// WebSocket client that connects to a transcription server and handles streaming, JSON messages, and retries.
|
||||
class AudioWebSocket: NSObject, URLSessionWebSocketDelegate {
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var urlSession: URLSession!
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private var retryCount = 0
|
||||
private let maxRetries = 3
|
||||
private var uid: String
|
||||
private let modelSize: String
|
||||
private var pingTimer: Timer?
|
||||
private var processedTexts = Set<String>()
|
||||
|
||||
var onServerReady: (() -> Void)?
|
||||
var onTranscriptionReceived: ((String) -> Void)?
|
||||
|
||||
init(host: String, port: Int, modelSize: String = "medium") {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.uid = UUID().uuidString
|
||||
self.modelSize = modelSize
|
||||
super.init()
|
||||
|
||||
self.urlSession = URLSession(
|
||||
configuration: .default,
|
||||
delegate: self,
|
||||
delegateQueue: .main
|
||||
)
|
||||
connect()
|
||||
}
|
||||
|
||||
/// Establishes a WebSocket connection with the configured server.
|
||||
private func connect() {
|
||||
guard retryCount <= maxRetries else {
|
||||
print("Maximum reconnect attempts exceeded.")
|
||||
return
|
||||
}
|
||||
|
||||
let socketURL = port == 443 || port == 80
|
||||
? "wss://\(host)"
|
||||
: "wss://\(host):\(port)"
|
||||
|
||||
guard let url = URL(string: socketURL) else {
|
||||
print("Invalid URL: \(socketURL)")
|
||||
return
|
||||
}
|
||||
|
||||
webSocketTask = urlSession.webSocketTask(with: url)
|
||||
webSocketTask?.resume()
|
||||
print("Attempting WebSocket connection: \(socketURL)")
|
||||
|
||||
listen()
|
||||
sendInitialJSON()
|
||||
startPing()
|
||||
}
|
||||
|
||||
/// Sends the initial JSON payload to identify and configure the session.
|
||||
private func sendInitialJSON() {
|
||||
let jsonPayload: [String: Any] = [
|
||||
"uid": uid,
|
||||
"language": "en",
|
||||
"task": "transcribe",
|
||||
"model": modelSize,
|
||||
"use_vad": true,
|
||||
"max_clients": 4,
|
||||
"max_connection_time": 600
|
||||
]
|
||||
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: jsonPayload, options: [])
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) ?? ""
|
||||
print("Sending config JSON: \(jsonString)")
|
||||
|
||||
webSocketTask?.send(.string(jsonString)) { [weak self] error in
|
||||
if let error = error {
|
||||
print("Failed to send config JSON: \(error.localizedDescription)")
|
||||
self?.reconnect()
|
||||
} else {
|
||||
print("Config JSON sent successfully.")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("JSON serialization error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends audio data to the server.
|
||||
func sendDataToServer(_ data: Data) {
|
||||
guard isConnected else {
|
||||
print("Not connected - skipping data send.")
|
||||
reconnect()
|
||||
return
|
||||
}
|
||||
|
||||
webSocketTask?.send(.data(data)) { [weak self] error in
|
||||
if let error = error {
|
||||
print("Failed to send audio data: \(error.localizedDescription)")
|
||||
self?.reconnect()
|
||||
} else {
|
||||
print("Sent audio data: \(data.count) bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the WebSocket is currently connected.
|
||||
internal var isConnected: Bool {
|
||||
webSocketTask?.state == .running
|
||||
}
|
||||
|
||||
/// Attempts reconnection with exponential backoff.
|
||||
private func reconnect() {
|
||||
retryCount += 1
|
||||
stopPing()
|
||||
let delay = min(5.0, pow(2.0, Double(retryCount)))
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
print("Reconnecting... (\(self?.retryCount ?? 0)/\(self?.maxRetries ?? 0))")
|
||||
self?.connect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts listening for incoming messages from the server.
|
||||
private func listen() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self?.handleMessage(message)
|
||||
self?.listen()
|
||||
case .failure(let error):
|
||||
print("Receive error: \(error.localizedDescription)")
|
||||
self?.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles incoming WebSocket messages (text or binary).
|
||||
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
||||
switch message {
|
||||
case .data(let data):
|
||||
print("Received binary data: \(data.count) bytes")
|
||||
|
||||
case .string(let text):
|
||||
print("Received text message: \(text)")
|
||||
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let status = json["status"] as? String {
|
||||
handleStatusMessage(status: status, message: json["message"] as? String)
|
||||
return
|
||||
}
|
||||
|
||||
if let message = json["message"] as? String, message == "SERVER_READY" {
|
||||
print("Server is ready.")
|
||||
onServerReady?()
|
||||
return
|
||||
}
|
||||
|
||||
if let segments = json["segments"] as? [[String: Any]] {
|
||||
let wrapped = ["segments": segments]
|
||||
let segmentData = try JSONSerialization.data(withJSONObject: wrapped, options: [])
|
||||
let segmentString = String(data: segmentData, encoding: .utf8)!
|
||||
onTranscriptionReceived?(segmentString)
|
||||
print("Transcription segments forwarded.")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("JSON parsing error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
print("Unknown message type received.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles status message JSON from the server.
|
||||
private func handleStatusMessage(status: String, message: String?) {
|
||||
switch status {
|
||||
case "WAIT":
|
||||
print("Waiting: \(message ?? "")")
|
||||
case "ERROR":
|
||||
print("Error: \(message ?? "")")
|
||||
case "WARNING":
|
||||
print("Warning: \(message ?? "")")
|
||||
default:
|
||||
print("\(status): \(message ?? "")")
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the "END_OF_AUDIO" signal to the server.
|
||||
func sendEndOfAudio() {
|
||||
guard isConnected else {
|
||||
print("Not connected - skipping END_OF_AUDIO.")
|
||||
return
|
||||
}
|
||||
|
||||
webSocketTask?.send(.string("END_OF_AUDIO")) { error in
|
||||
if let error = error {
|
||||
print("Failed to send END_OF_AUDIO: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("END_OF_AUDIO sent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gracefully closes the WebSocket connection.
|
||||
func closeConnection() {
|
||||
stopPing()
|
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
retryCount = maxRetries
|
||||
print("WebSocket closed.")
|
||||
}
|
||||
|
||||
/// Starts periodic ping to keep the WebSocket alive.
|
||||
private func startPing() {
|
||||
stopPing()
|
||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in
|
||||
self?.webSocketTask?.sendPing { error in
|
||||
if let error = error {
|
||||
print("Ping failed: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Ping sent successfully.")
|
||||
}
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(pingTimer!, forMode: .common)
|
||||
}
|
||||
|
||||
/// Stops the periodic ping timer.
|
||||
private func stopPing() {
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = nil
|
||||
}
|
||||
|
||||
/// Called when the WebSocket is closed by the server.
|
||||
func urlSession(_ session: URLSession,
|
||||
webSocketTask: URLSessionWebSocketTask,
|
||||
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||
reason: Data?) {
|
||||
let reasonString = String(data: reason ?? Data(), encoding: .utf8) ?? "No reason"
|
||||
print("WebSocket closed - code: \(closeCode.rawValue), reason: \(reasonString)")
|
||||
stopPing()
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user