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) } } }