Files
OSGKeyboard/OSGKeyboardShared/Services/CloudASR/OpenAIRealtimeASRClient.swift
T
Rocky d656bac8c3 feat: add streaming cloud ASR, polish routing, and settings card layout
Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
2026-07-28 20:25:17 +08:00

392 lines
14 KiB
Swift

// OpenAIRealtimeASRClient.swift
// OSGKeyboard · Shared
//
// OpenAI Realtime transcription (WebSocket). Streams PCM and transcript
// deltas for utterance-level ASR. Batch `/audio/transcriptions` remains the
// fallback path when realtime is unavailable.
import Foundation
import os
struct OpenAIRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
let apiKey: String
let endpoint: String
let model: String
let session: URLSession
/// Used when streaming fails and Flow falls back to chunked batch ASR.
private let batchClient: PromptCloudASRClient
static let appendChunkBytes = 4_800 // 100 ms @ 24 kHz / 16-bit mono.
static let finalTimeout: TimeInterval = 15
init(
apiKey: String,
endpoint: String,
model: String,
batchBaseURL: String,
session: URLSession
) {
self.apiKey = apiKey
self.endpoint = endpoint
self.model = model
self.session = session
self.batchClient = PromptCloudASRClient(
providerId: "openai",
baseURL: batchBaseURL.isEmpty ? "https://api.openai.com/v1" : batchBaseURL,
apiKey: apiKey,
model: Self.batchModel(from: model),
session: session
)
}
func prepare(dictionary: PersonalDictionary) async throws {}
func openStreamingSession(
locale: Locale,
dictionary: PersonalDictionary,
onPartial: @escaping @Sendable (String) -> Void
) async throws -> any CloudASRStreamingSession {
_ = dictionary
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey }
let url = try resolvedEndpointURL()
var request = URLRequest(url: url)
request.timeoutInterval = 8
request.setValue(
"Bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))",
forHTTPHeaderField: "Authorization"
)
let wsTask = session.webSocketTask(with: request)
wsTask.resume()
let live = OpenAIRealtimeStreamingSession(
wsTask: wsTask,
model: resolvedRealtimeModel,
locale: locale,
onPartial: onPartial
)
try await live.start()
return live
}
func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
dictionary: PersonalDictionary
) async throws -> String {
try await batchClient.transcribe(
samples: samples,
sampleRate: sampleRate,
locale: locale,
dictionary: dictionary
)
}
func probeConnection() async throws {
do {
let session = try await openStreamingSession(
locale: Locale(identifier: "zh-CN"),
dictionary: .empty,
onPartial: { _ in }
)
session.cancel()
} catch {
try await batchClient.probeConnection()
}
}
private var resolvedRealtimeModel: String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.hasPrefix("gpt-4o") || trimmed == "whisper-1" {
return CloudASRModelCatalog.openAIRealtimeWhisper
}
return trimmed
}
private func resolvedEndpointURL() throws -> URL {
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.hasPrefix("wss://") || raw.hasPrefix("ws://") {
guard let url = URL(string: raw) else { throw CloudASRError.invalidURL }
return url
}
guard let url = URL(string: CloudASRModelCatalog.openAIRealtimeEndpoint) else {
throw CloudASRError.invalidURL
}
return url
}
private static func batchModel(from model: String) -> String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.contains("realtime") {
return CloudASRModelCatalog.openAITranscribe
}
return trimmed
}
}
// MARK: - Utterance session
private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @unchecked Sendable {
private let wsTask: URLSessionWebSocketTask
private let model: String
private let locale: Locale
private let onPartial: @Sendable (String) -> Void
private let lock = OSAllocatedUnfairLock()
private var receiveTask: Task<Void, Never>?
private var failure: Error?
private var sessionReady = false
private var finished = false
private var pcmBuffer = Data()
private var partialByItem: [String: String] = [:]
private var completedByItem: [String: String] = [:]
private var itemOrder: [String] = []
private var awaitingCommit = false
init(
wsTask: URLSessionWebSocketTask,
model: String,
locale: Locale,
onPartial: @escaping @Sendable (String) -> Void
) {
self.wsTask = wsTask
self.model = model
self.locale = locale
self.onPartial = onPartial
}
func start() async throws {
receiveTask = Task { [weak self] in
await self?.receiveLoop()
}
let language = Self.languageHint(from: locale)
var transcription: [String: Any] = [
"model": model,
"delay": "low",
]
if let language {
transcription["language"] = language
}
var input: [String: Any] = [
"format": [
"type": "audio/pcm",
"rate": 24_000,
],
"transcription": transcription,
]
input["turn_detection"] = NSNull()
let update: [String: Any] = [
"type": "session.update",
"session": [
"type": "transcription",
"audio": [
"input": input,
],
],
]
try await sendJSON(update)
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
try throwIfFailed()
if lock.withLock({ sessionReady }) { return }
try await Task.sleep(nanoseconds: 20_000_000)
}
cancel()
throw CloudASRError.transport("OpenAI realtime session timed out")
}
func append(samples: [Float]) async throws {
try throwIfFailed()
let upsampled = CloudASRStreamingPCM.upsample16kTo24k(samples)
let pcm = CloudASRStreamingPCM.pcm16LE(samples: upsampled)
let frames: [Data] = lock.withLock {
pcmBuffer.append(pcm)
var frames: [Data] = []
while pcmBuffer.count >= OpenAIRealtimeASRClient.appendChunkBytes {
let frame = pcmBuffer.prefix(OpenAIRealtimeASRClient.appendChunkBytes)
frames.append(Data(frame))
pcmBuffer.removeFirst(OpenAIRealtimeASRClient.appendChunkBytes)
}
return frames
}
for frame in frames {
try await sendAppend(frame)
}
}
func finish() async throws -> String {
try throwIfFailed()
let trailing: Data = lock.withLock {
let data = pcmBuffer
pcmBuffer.removeAll(keepingCapacity: false)
awaitingCommit = true
return data
}
if !trailing.isEmpty {
try await sendAppend(trailing)
}
try await sendJSON(["type": "input_audio_buffer.commit"])
let deadline = Date().addingTimeInterval(OpenAIRealtimeASRClient.finalTimeout)
while Date() < deadline {
try throwIfFailed()
let snapshot = lock.withLock { (awaitingCommit, composedFinal(), composedDisplay()) }
if !snapshot.0 {
let text = snapshot.1.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? snapshot.2
: snapshot.1
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
cancel()
if trimmed.isEmpty { throw CloudASRError.emptyTranscript }
return trimmed
}
let settled = lock.withLock {
!completedByItem.isEmpty && partialByItem.isEmpty && !awaitingCommit
}
if settled {
let text = lock.withLock { composedFinal() }
.trimmingCharacters(in: .whitespacesAndNewlines)
cancel()
if text.isEmpty { throw CloudASRError.emptyTranscript }
return text
}
try await Task.sleep(nanoseconds: 20_000_000)
}
let fallback = lock.withLock {
let final = composedFinal()
return final.isEmpty ? composedDisplay() : final
}
.trimmingCharacters(in: .whitespacesAndNewlines)
cancel()
if fallback.isEmpty {
throw CloudASRError.transport("OpenAI realtime final timed out")
}
return fallback
}
func cancel() {
receiveTask?.cancel()
wsTask.cancel(with: .normalClosure, reason: nil)
lock.withLock { finished = true }
}
private func receiveLoop() async {
while !Task.isCancelled {
let message: URLSessionWebSocketTask.Message
do {
message = try await wsTask.receive()
} catch {
publishFailure(CloudASRError.transport(error.localizedDescription))
return
}
let text: String
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8) ?? ""
@unknown default:
continue
}
guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
let type = json["type"] as? String else {
continue
}
switch type {
case "session.created", "session.updated":
lock.withLock { sessionReady = true }
case "conversation.item.input_audio_transcription.delta":
let itemID = json["item_id"] as? String ?? "default"
let delta = json["delta"] as? String ?? ""
guard !delta.isEmpty else { continue }
let display = lock.withLock { () -> String in
if partialByItem[itemID] == nil, completedByItem[itemID] == nil {
itemOrder.append(itemID)
}
partialByItem[itemID, default: ""] += delta
return composedDisplay()
}
if !display.isEmpty { onPartial(display) }
case "conversation.item.input_audio_transcription.completed":
let itemID = json["item_id"] as? String ?? "default"
let transcript = (json["transcript"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let display = lock.withLock { () -> String in
if !itemOrder.contains(itemID) {
itemOrder.append(itemID)
}
if !transcript.isEmpty {
completedByItem[itemID] = transcript
}
partialByItem.removeValue(forKey: itemID)
awaitingCommit = false
return composedDisplay()
}
if !display.isEmpty { onPartial(display) }
case "error":
let message = ((json["error"] as? [String: Any])?["message"] as? String)
?? "OpenAI realtime error"
publishFailure(CloudASRError.transport(message))
return
default:
break
}
}
}
private func composedDisplay() -> String {
itemOrder.compactMap { id in
completedByItem[id] ?? partialByItem[id]
}
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func composedFinal() -> String {
itemOrder.compactMap { completedByItem[$0] }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func sendAppend(_ pcm: Data) async throws {
let audio = pcm.base64EncodedString()
try await sendJSON([
"type": "input_audio_buffer.append",
"audio": audio,
])
}
private func sendJSON(_ body: [String: Any]) async throws {
guard JSONSerialization.isValidJSONObject(body),
let data = try? JSONSerialization.data(withJSONObject: body),
let string = String(data: data, encoding: .utf8) else {
throw CloudASRError.decoding("invalid realtime payload")
}
do {
try await wsTask.send(.string(string))
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
}
private func throwIfFailed() throws {
let (error, done) = lock.withLock { (failure, finished) }
if let error { throw error }
if done { throw CloudASRError.transport("OpenAI realtime session cancelled") }
}
private func publishFailure(_ error: Error) {
lock.withLock { failure = error }
cancel()
}
private static func languageHint(from locale: Locale) -> String? {
let id = locale.identifier.lowercased()
if id.hasPrefix("zh") { return "zh" }
if id.hasPrefix("en") { return "en" }
if id.hasPrefix("ja") { return "ja" }
if id.hasPrefix("ko") { return "ko" }
return nil
}
}