197 lines
6.4 KiB
Swift
197 lines
6.4 KiB
Swift
import Cocoa
|
||
|
||
/// 应用主控制器,协调按键监听、语音识别、LLM 纠错和文本注入
|
||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||
|
||
private let keyMonitor = KeyMonitor()
|
||
private let speechRecognizer = SpeechRecognizer()
|
||
private let audioMonitor = AudioLevelMonitor()
|
||
private let recordingPanel = RecordingPanel()
|
||
private let menuBarController = MenuBarController()
|
||
private let llmRefiner = LLMRefiner()
|
||
|
||
private var isRecording = false
|
||
private var finalTranscription: String = ""
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
NSApp.setActivationPolicy(.accessory)
|
||
|
||
// 检查辅助功能权限
|
||
if !CGPreflightListenEventAccess() {
|
||
requestAccessibilityPermission()
|
||
} else {
|
||
startKeyMonitor()
|
||
}
|
||
|
||
print("[App] Ready")
|
||
}
|
||
|
||
// MARK: - Permissions
|
||
|
||
private func requestAccessibilityPermission() {
|
||
// 自动打开系统设置
|
||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
||
|
||
// 显示提示
|
||
let alert = NSAlert()
|
||
alert.messageText = "需要辅助功能权限"
|
||
alert.informativeText = """
|
||
Voice Input 需要「辅助功能」权限才能监听 Fn 键。
|
||
|
||
系统设置已自动打开,请:
|
||
1. 点击左下角的锁并输入密码
|
||
2. 在列表中找到并勾选「VoiceInput」
|
||
3. 返回本应用
|
||
|
||
点击「继续」后,应用将每 2 秒检查一次权限状态。
|
||
"""
|
||
alert.alertStyle = .informational
|
||
alert.addButton(withTitle: "继续")
|
||
alert.addButton(withTitle: "退出")
|
||
|
||
if alert.runModal() == .alertFirstButtonReturn {
|
||
// 轮询检查权限
|
||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
|
||
if CGPreflightListenEventAccess() {
|
||
timer.invalidate()
|
||
DispatchQueue.main.async {
|
||
self?.startKeyMonitor()
|
||
// 显示成功提示
|
||
let successAlert = NSAlert()
|
||
successAlert.messageText = "权限已授权"
|
||
successAlert.informativeText = "辅助功能权限已开启,Voice Input 正在运行。"
|
||
successAlert.alertStyle = .informational
|
||
successAlert.runModal()
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|
||
|
||
private func startKeyMonitor() {
|
||
let started = keyMonitor.start()
|
||
if !started {
|
||
print("[App] Failed to start key monitor")
|
||
return
|
||
}
|
||
|
||
keyMonitor.onFnDown = { [weak self] in
|
||
self?.startRecording()
|
||
}
|
||
|
||
keyMonitor.onFnUp = { [weak self] in
|
||
self?.stopRecording()
|
||
}
|
||
}
|
||
|
||
// MARK: - Recording
|
||
|
||
private func startRecording() {
|
||
guard !isRecording else { return }
|
||
isRecording = true
|
||
finalTranscription = ""
|
||
|
||
recordingPanel.showAnimated()
|
||
recordingPanel.updateText("Listening...")
|
||
|
||
let language = Config.shared.language.rawValue
|
||
|
||
Task { @MainActor in
|
||
// 启动音频电平监测
|
||
let audioOK = audioMonitor.start()
|
||
if audioOK {
|
||
audioMonitor.onLevelUpdate = { [weak self] level in
|
||
self?.recordingPanel.waveformView.updateLevel(level)
|
||
}
|
||
}
|
||
|
||
// 启动语音识别
|
||
let speechOK = await speechRecognizer.start(language: language)
|
||
if !speechOK {
|
||
recordingPanel.updateText("Recognition error")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||
self?.cancelRecording()
|
||
}
|
||
return
|
||
}
|
||
|
||
speechRecognizer.onResult = { [weak self] text in
|
||
guard let self = self, self.isRecording else { return }
|
||
self.finalTranscription = text
|
||
self.recordingPanel.updateText(text)
|
||
}
|
||
|
||
speechRecognizer.onError = { error in
|
||
let nsError = error as NSError
|
||
// kAFAssistantErrorDomain code 1110 = 未检测到语音,属正常情况,不报错
|
||
if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 {
|
||
return
|
||
}
|
||
print("[App] Speech error: \(error)")
|
||
}
|
||
|
||
speechRecognizer.onStateChange = { [weak self] isActive in
|
||
if !isActive, self?.isRecording == true {
|
||
// 识别意外停止
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
|
||
// 停止音频和识别
|
||
audioMonitor.stop()
|
||
_ = speechRecognizer.stop()
|
||
|
||
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// 如果 LLM 启用且已配置
|
||
if Config.shared.llmEnabled && Config.shared.isLLMConfigured && !text.isEmpty {
|
||
recordingPanel.showRefining()
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let result = try await llmRefiner.refine(text: text, config: Config.shared)
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(result.refinedText)
|
||
}
|
||
} catch {
|
||
print("[App] LLM error: \(error)")
|
||
// LLM 失败,回退到原始文本
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(text)
|
||
}
|
||
}
|
||
}
|
||
} else if !text.isEmpty {
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(text)
|
||
}
|
||
} else {
|
||
recordingPanel.hideAnimated()
|
||
}
|
||
|
||
finalTranscription = ""
|
||
}
|
||
|
||
private func cancelRecording() {
|
||
audioMonitor.stop()
|
||
speechRecognizer.cancel()
|
||
recordingPanel.hideAnimated()
|
||
isRecording = false
|
||
finalTranscription = ""
|
||
}
|
||
|
||
func applicationWillTerminate(_ notification: Notification) {
|
||
keyMonitor.stop()
|
||
audioMonitor.stop()
|
||
speechRecognizer.cancel()
|
||
recordingPanel.close()
|
||
}
|
||
}
|