import Foundation enum LocalWhisper { private static let server = LocalWhisperServer() static func validate(settings: AppSettings) throws { guard FileManager.default.isExecutableFile(atPath: settings.localBinaryPath) else { throw WhisperKeyError.missingDependency(settings.localBinaryPath) } guard FileManager.default.fileExists(atPath: settings.localModelPath) else { throw WhisperKeyError.missingDependency(settings.localModelPath) } guard FileManager.default.fileExists(atPath: vadModelPath) else { throw WhisperKeyError.missingDependency(vadModelPath) } guard FileManager.default.isExecutableFile(atPath: serverBinaryPath) else { throw WhisperKeyError.missingDependency(serverBinaryPath) } } private static let vadModelPath = "\(NSHomeDirectory())/Library/Application Support/WhisperKey/ggml-silero-v6.2.0.bin" private static let serverBinaryPath = "\(NSHomeDirectory())/Library/Application Support/WhisperKey/whisper-server" static func preload(settings: AppSettings) async throws { try validate(settings: settings) try await server.ensureRunning( binaryPath: serverBinaryPath, modelPath: settings.localModelPath, vadModelPath: vadModelPath ) } static func transcribe(wav: URL, settings: AppSettings) async throws -> String { try validate(settings: settings) return try await server.transcribe( wav: wav, binaryPath: serverBinaryPath, modelPath: settings.localModelPath, vadModelPath: vadModelPath ) } } private actor LocalWhisperServer { private let baseURL = URL(string: "http://127.0.0.1:8178")! private var process: Process? func ensureRunning(binaryPath: String, modelPath: String, vadModelPath: String) async throws { if await isHealthy() { return } if process?.isRunning != true { let process = Process() process.executableURL = URL(fileURLWithPath: binaryPath) process.arguments = [ "-m", modelPath, "--host", "127.0.0.1", "--port", "8178", "-l", "ru", "-nt", "-nf", "-sns", "--no-speech-thold", "0.35", "--vad", "--vad-model", vadModelPath, "--vad-threshold", "0.55", "--vad-min-speech-duration-ms", "250", "--vad-min-silence-duration-ms", "300", "--vad-speech-pad-ms", "100", "-t", "8", ] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try process.run() self.process = process } for _ in 0..<300 { if await isHealthy() { return } try await Task.sleep(for: .milliseconds(100)) } throw WhisperKeyError.processFailed("Локальный Whisper не успел загрузить модель.") } func transcribe( wav: URL, binaryPath: String, modelPath: String, vadModelPath: String ) async throws -> String { try await ensureRunning( binaryPath: binaryPath, modelPath: modelPath, vadModelPath: vadModelPath ) let boundary = "WhisperKey-\(UUID().uuidString)" var request = URLRequest(url: baseURL.appendingPathComponent("inference")) request.httpMethod = "POST" request.timeoutInterval = 60 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() body.appendField("language", value: "ru", boundary: boundary) body.appendField("temperature", value: "0", boundary: boundary) body.appendField("no_speech_thold", value: "0.35", boundary: boundary) body.appendField("response_format", value: "json", boundary: boundary) body.appendFile( "file", filename: "dictation.wav", mime: "audio/wav", data: try Data(contentsOf: wav), boundary: boundary ) body.append("--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw WhisperKeyError.badResponse(String(data: data, encoding: .utf8) ?? "Ошибка локального Whisper.") } return try JSONDecoder().decode(TranscriptionResponse.self, from: data).text } private func isHealthy() async -> Bool { var request = URLRequest(url: baseURL.appendingPathComponent("health")) request.timeoutInterval = 0.5 guard let (_, response) = try? await URLSession.shared.data(for: request), let http = response as? HTTPURLResponse else { return false } return (200..<300).contains(http.statusCode) } } enum OpenAITranscriber { static func transcribe(wav: URL, settings: AppSettings) async throws -> String { guard let key = settings.apiKey(), !key.isEmpty else { throw WhisperKeyError.missingAPIKey } let boundary = "WhisperKey-\(UUID().uuidString)" var request = URLRequest(url: URL(string: "https://api.openai.com/v1/audio/transcriptions")!) request.httpMethod = "POST" request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() body.appendField("model", value: settings.cloudModel, boundary: boundary) body.appendField("language", value: "ru", boundary: boundary) body.appendField("temperature", value: "0", boundary: boundary) body.appendField( "prompt", value: "Дословная русская транскрипция. Не добавляй слова, которых нет в аудио. Термины: \(settings.glossary)", boundary: boundary ) body.appendFile("file", filename: "dictation.wav", mime: "audio/wav", data: try Data(contentsOf: wav), boundary: boundary) body.append("--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) .flatMap { $0["error"] as? [String: Any] }?["message"] as? String throw WhisperKeyError.badResponse(message ?? "OpenAI API вернул ошибку.") } let result = try JSONDecoder().decode(TranscriptionResponse.self, from: data) return result.text } } private struct TranscriptionResponse: Decodable { let text: String } private extension Data { mutating func appendField(_ name: String, value: String, boundary: String) { append("--\(boundary)\r\n".data(using: .utf8)!) append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) append("\(value)\r\n".data(using: .utf8)!) } mutating func appendFile(_ name: String, filename: String, mime: String, data: Data, boundary: String) { append("--\(boundary)\r\n".data(using: .utf8)!) append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) append("Content-Type: \(mime)\r\n\r\n".data(using: .utf8)!) append(data) append("\r\n".data(using: .utf8)!) } }