vOOice/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift
Beingpax 948033ac28 Migrate API key storage to Keychain with iCloud sync
Move API keys from UserDefaults to secure Keychain storage. Add KeychainService and APIKeyManager for centralized key management. Enable iCloud Keychain sync for cross-device sharing between macOS and iOS.
2026-01-05 22:28:34 +05:45

86 lines
4.0 KiB
Swift

import Foundation
import OSLog
class ElevenLabsTranscriptionService {
private let apiURL = URL(string: "https://api.elevenlabs.io/v1/speech-to-text")!
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService")
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "ElevenLabs"), !apiKey.isEmpty else {
throw CloudTranscriptionError.missingAPIKey
}
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(apiKey, forHTTPHeaderField: "xi-api-key")
let body = try createRequestBody(audioURL: audioURL, modelName: model.name, boundary: boundary)
let (data, response) = try await URLSession.shared.upload(for: request, from: body)
guard let httpResponse = response as? HTTPURLResponse else {
throw CloudTranscriptionError.networkError(URLError(.badServerResponse))
}
logger.notice("ElevenLabs API Response Status: \(httpResponse.statusCode)")
if let responseBody = String(data: data, encoding: .utf8) {
logger.notice("ElevenLabs API Response Body: \(responseBody)")
}
if !(200...299).contains(httpResponse.statusCode) {
let errorMessage = String(data: data, encoding: .utf8) ?? "No error message"
throw CloudTranscriptionError.apiRequestFailed(statusCode: httpResponse.statusCode, message: errorMessage)
}
do {
let transcriptionResponse = try JSONDecoder().decode(ElevenLabsTranscriptionResponse.self, from: data)
return transcriptionResponse.text
} catch {
throw CloudTranscriptionError.noTranscriptionReturned
}
}
private func createRequestBody(audioURL: URL, modelName: String, boundary: String) throws -> Data {
var body = Data()
body.append(formField: "file", fileName: audioURL.lastPathComponent, fileData: try Data(contentsOf: audioURL), mimeType: "audio/wav", boundary: boundary)
body.append(formField: "model_id", value: modelName, boundary: boundary)
body.append(formField: "temperature", value: "0.0", boundary: boundary)
body.append(formField: "tag_audio_events", value: "false", boundary: boundary)
let selectedLanguage = UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto"
if selectedLanguage != "auto", !selectedLanguage.isEmpty {
body.append(formField: "language_code", value: selectedLanguage, boundary: boundary)
}
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
return body
}
private struct ElevenLabsTranscriptionResponse: Decodable {
let text: String
}
}
private extension Data {
mutating func append(formField: String, value: String, boundary: String) {
let crlf = "\r\n"
append("--\(boundary)\(crlf)".data(using: .utf8)!)
append("Content-Disposition: form-data; name=\"\(formField)\"\(crlf)\(crlf)".data(using: .utf8)!)
append(value.data(using: .utf8)!)
append(crlf.data(using: .utf8)!)
}
mutating func append(formField: String, fileName: String, fileData: Data, mimeType: String, boundary: String) {
let crlf = "\r\n"
append("--\(boundary)\(crlf)".data(using: .utf8)!)
append("Content-Disposition: form-data; name=\"\(formField)\"; filename=\"\(fileName)\"\(crlf)".data(using: .utf8)!)
append("Content-Type: \(mimeType)\(crlf)\(crlf)".data(using: .utf8)!)
append(fileData)
append(crlf.data(using: .utf8)!)
}
}