Первый релиз WhisperKey
This commit is contained in:
@@ -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)!)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user