Первый релиз WhisperKey

This commit is contained in:
2026-07-29 14:46:12 +06:00
commit 5751743277
19 changed files with 1436 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
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)!)
}
}