blitztext-app-linux/BlitztextMac/Services/TranscriptionService.swift
2026-05-23 07:28:46 +02:00

136 lines
4.9 KiB
Swift

import Foundation
enum TranscriptionError: LocalizedError {
case noFile
case notConfigured
case networkError(String)
case apiError(String)
var errorDescription: String? {
switch self {
case .noFile:
return "Keine Audio-Datei gefunden"
case .notConfigured:
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
case .networkError(let msg):
return "Netzwerkfehler: \(msg)"
case .apiError(let msg):
return "OpenAI-Fehler: \(msg)"
}
}
}
private struct TranscriptionOpenAIErrorResponse: Decodable {
struct APIError: Decodable {
let message: String?
}
let error: APIError?
}
enum TranscriptionService {
private static let remoteModel = "whisper-1"
private static let transcriptionsURL = URL(string: "https://api.openai.com/v1/audio/transcriptions")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.waitsForConnectivity = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 60
configuration.timeoutIntervalForResource = 60
return URLSession(configuration: configuration)
}()
static func transcribe(
audioURL: URL,
customTerms: [String] = [],
language: String? = nil
) async throws -> String {
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
throw TranscriptionError.notConfigured
}
return try await Task.detached(priority: .userInitiated) {
defer {
try? FileManager.default.removeItem(at: audioURL)
}
let boundary = UUID().uuidString
var request = URLRequest(url: transcriptionsURL)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("text/plain, application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 60
request.cachePolicy = .reloadIgnoringLocalCacheData
let audioData = try Data(contentsOf: audioURL, options: [.mappedIfSafe])
var body = Data()
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"audio.m4a\"\r\n")
body.append("Content-Type: audio/m4a\r\n\r\n")
body.append(audioData)
body.append("\r\n")
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"model\"\r\n\r\n")
body.append(remoteModel)
body.append("\r\n")
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"response_format\"\r\n\r\n")
body.append("text")
body.append("\r\n")
if !customTerms.isEmpty {
let prompt = "Eigennamen und Begriffe: \(customTerms.joined(separator: ", "))"
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"prompt\"\r\n\r\n")
body.append(prompt)
body.append("\r\n")
}
if let language, !language.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
body.append("--\(boundary)\r\n")
body.append("Content-Disposition: form-data; name=\"language\"\r\n\r\n")
body.append(language.trimmingCharacters(in: .whitespacesAndNewlines))
body.append("\r\n")
}
body.append("--\(boundary)--\r\n")
request.httpBody = body
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TranscriptionError.networkError("Ungueltige Antwort")
}
guard httpResponse.statusCode == 200 else {
throw TranscriptionError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
}
guard let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else {
throw TranscriptionError.apiError("Transkription fehlgeschlagen")
}
return text
}.value
}
private static func openAIErrorMessage(from data: Data) -> String? {
(try? JSONDecoder().decode(TranscriptionOpenAIErrorResponse.self, from: data))?.error?.message
}
}
private extension Data {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
append(data)
}
}
}