From 1d9c9d5fa03a8ed43f30972c7c1d501fc3722411 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 24 Apr 2026 16:17:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20macOS=20=E8=8F=9C=E5=8D=95=E6=A0=8F?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=85=A5=E5=BA=94=E7=94=A8=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CGEvent cghidEventTap 全局监听 Fn 键 - Apple Speech 流式语音识别(zh-CN 默认) - 实时音频 RMS 电平驱动波形动画 - NSPanel 胶囊浮动窗口 - 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换 - OpenAI 兼容 LLM 纠错(DeepSeek 预设) - LSUIElement 菜单栏运行 - SPM + Makefile 构建 --- .gitignore | 3 + Makefile | 84 +++++++++ Package.swift | 21 +++ Sources/App/AppDelegate.swift | 159 ++++++++++++++++ Sources/App/MenuBarController.swift | 142 ++++++++++++++ Sources/Core/AudioLevelMonitor.swift | 66 +++++++ Sources/Core/InputSourceManager.swift | 71 +++++++ Sources/Core/KeyMonitor.swift | 130 +++++++++++++ Sources/Core/LLMRefiner.swift | 97 ++++++++++ Sources/Core/SpeechRecognizer.swift | 133 ++++++++++++++ Sources/Core/TextInjector.swift | 105 +++++++++++ Sources/Resources/Info.plist | 30 +++ Sources/UI/RecordingPanel.swift | 254 ++++++++++++++++++++++++++ Sources/UI/SettingsWindow.swift | 207 +++++++++++++++++++++ Sources/UI/WaveformView.swift | 100 ++++++++++ Sources/Utils/Config.swift | 70 +++++++ Sources/main.swift | 8 + scripts/create_app_bundle.sh | 33 ++++ 18 files changed, 1713 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 Sources/App/AppDelegate.swift create mode 100644 Sources/App/MenuBarController.swift create mode 100644 Sources/Core/AudioLevelMonitor.swift create mode 100644 Sources/Core/InputSourceManager.swift create mode 100644 Sources/Core/KeyMonitor.swift create mode 100644 Sources/Core/LLMRefiner.swift create mode 100644 Sources/Core/SpeechRecognizer.swift create mode 100644 Sources/Core/TextInjector.swift create mode 100644 Sources/Resources/Info.plist create mode 100644 Sources/UI/RecordingPanel.swift create mode 100644 Sources/UI/SettingsWindow.swift create mode 100644 Sources/UI/WaveformView.swift create mode 100644 Sources/Utils/Config.swift create mode 100644 Sources/main.swift create mode 100755 scripts/create_app_bundle.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38f6021 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +*.app \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4497d2e --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +# VoiceInput Makefile +# macOS 14+ 菜单栏语音输入应用 + +APP_NAME := VoiceInput +BUILD_DIR := .build +RELEASE_DIR := $(BUILD_DIR)/release +PRODUCT := $(RELEASE_DIR)/$(APP_NAME) +APP_BUNDLE := $(RELEASE_DIR)/$(APP_NAME).app +INSTALL_DIR := /Applications/$(APP_NAME).app +ENTITLEMENTS := Sources/Resources/VoiceInput.entitlements + +.PHONY: all build run install clean debug release + +all: build + +# ============================================================ +# Build +# ============================================================ +build: debug + +debug: + @echo "🔨 Building Debug..." + swift build -c debug --disable-sandbox + @echo "📦 Creating app bundle..." + @./scripts/create_app_bundle.sh debug + @echo "✅ Build complete: $(APP_BUNDLE)" + +release: + @echo "🔨 Building Release..." + swift build -c release --disable-sandbox + @echo "📦 Creating app bundle..." + @./scripts/create_app_bundle.sh release + @echo "✅ Release build complete: $(APP_BUNDLE)" + +# ============================================================ +# Run +# ============================================================ +run: debug + @echo "🚀 Launching..." + open $(APP_BUNDLE) + +# ============================================================ +# Install +# ============================================================ +install: release + @echo "📥 Installing to $(INSTALL_DIR)..." + @if [ -d "$(INSTALL_DIR)" ]; then rm -rf "$(INSTALL_DIR)"; fi + cp -R $(APP_BUNDLE) $(INSTALL_DIR) + @echo "✅ Installed to $(INSTALL_DIR)" + +# ============================================================ +# Clean +# ============================================================ +clean: + @echo "🧹 Cleaning..." + swift package clean + rm -rf $(RELEASE_DIR) + @echo "✅ Clean" + +# ============================================================ +# Entitlements +# ============================================================ +entitlements: $(ENTITLEMENTS) + +$(ENTITLEMENTS): + @mkdir -p Sources/Resources + @echo '' > $(ENTITLEMENTS) + @echo '' >> $(ENTITLEMENTS) + @echo '' >> $(ENTITLEMENTS) + @echo '' >> $(ENTITLEMENTS) + @echo ' com.apple.security.device.audio-input' >> $(ENTITLEMENTS) + @echo ' ' >> $(ENTITLEMENTS) + @echo ' com.apple.security.cs.disable-library-validation' >> $(ENTITLEMENTS) + @echo ' ' >> $(ENTITLEMENTS) + @echo '' >> $(ENTITLEMENTS) + @echo '' >> $(ENTITLEMENTS) + +# ============================================================ +# Codesign (ad-hoc) +# ============================================================ +sign: release + @echo "🔏 Signing..." + codesign --force --deep --sign - $(APP_BUNDLE) + @echo "✅ Signed (ad-hoc)" diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..be041b0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "VoiceInput", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "VoiceInput", + path: "Sources", + linkerSettings: [ + .linkedFramework("Speech"), + .linkedFramework("AVFAudio"), + .linkedFramework("Carbon"), + .linkedFramework("CoreGraphics"), + .linkedFramework("AppKit"), + .linkedFramework("Foundation") + ] + ) + ] +) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift new file mode 100644 index 0000000..d223bfc --- /dev/null +++ b/Sources/App/AppDelegate.swift @@ -0,0 +1,159 @@ +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) + + // 启动按键监听 + let started = keyMonitor.start() + if !started { + showPermissionAlert() + } + + keyMonitor.onFnDown = { [weak self] in + self?.startRecording() + } + + keyMonitor.onFnUp = { [weak self] in + self?.stopRecording() + } + + print("[App] Ready") + } + + // 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 = { [weak self] error in + print("[App] Speech error: \(error)") + self?.recordingPanel.updateText("Error: \(error.localizedDescription)") + } + + 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 = "" + } + + // MARK: - Permissions + + private func showPermissionAlert() { + let alert = NSAlert() + alert.messageText = "Accessibility Permission Required" + alert.informativeText = """ + Voice Input needs Accessibility permission to detect the Fn key globally. + + Please go to System Settings > Privacy & Security > Accessibility + and enable Voice Input. + """ + alert.alertStyle = .warning + alert.addButton(withTitle: "Open System Settings") + alert.addButton(withTitle: "Later") + + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) + } + } + + func applicationWillTerminate(_ notification: Notification) { + keyMonitor.stop() + audioMonitor.stop() + speechRecognizer.cancel() + recordingPanel.close() + } +} diff --git a/Sources/App/MenuBarController.swift b/Sources/App/MenuBarController.swift new file mode 100644 index 0000000..98745b4 --- /dev/null +++ b/Sources/App/MenuBarController.swift @@ -0,0 +1,142 @@ +import Cocoa + +/// 菜单栏控制器:管理状态栏图标和菜单 +final class MenuBarController: NSObject { + + private let statusItem: NSStatusItem + private let menu = NSMenu() + private var languageMenuItems: [NSMenuItem] = [] + + // 子菜单引用 + private let llmSubmenu = NSMenu() + private let llmEnabledItem: NSMenuItem + private let settingsWindow = SettingsWindow() + + override init() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + // LLM 启用/禁用 + llmEnabledItem = NSMenuItem( + title: "Enable LLM Refinement", + action: #selector(toggleLLM), + keyEquivalent: "" + ) + + super.init() + + configureStatusItem() + buildMenu() + } + + private func configureStatusItem() { + if let button = statusItem.button { + button.image = NSImage( + systemSymbolName: "mic.fill", + accessibilityDescription: "Voice Input" + ) + button.toolTip = "Voice Input — 按住 Fn 键录音" + } + } + + private func buildMenu() { + // 当前语言显示 + let currentLanguageItem = NSMenuItem( + title: "Language: \(Config.shared.language.displayName)", + action: nil, + keyEquivalent: "" + ) + currentLanguageItem.isEnabled = false + menu.addItem(currentLanguageItem) + menu.addItem(.separator()) + + // 语言选择子菜单 + let languageMenu = NSMenu() + for lang in RecognitionLanguage.allCases { + let item = NSMenuItem( + title: lang.displayName, + action: #selector(selectLanguage(_:)), + keyEquivalent: "" + ) + item.representedObject = lang + item.state = lang == Config.shared.language ? .on : .off + languageMenu.addItem(item) + languageMenuItems.append(item) + } + + let languageSubmenuItem = NSMenuItem() + languageSubmenuItem.title = "Language" + languageSubmenuItem.submenu = languageMenu + menu.addItem(languageSubmenuItem) + + menu.addItem(.separator()) + + // LLM 子菜单 + llmSubmenu.addItem(llmEnabledItem) + llmEnabledItem.target = self + llmEnabledItem.action = #selector(toggleLLM) + updateLLMMenuState() + + let settingsItem = NSMenuItem( + title: "LLM Settings...", + action: #selector(openSettings), + keyEquivalent: "," + ) + settingsItem.target = self + llmSubmenu.addItem(settingsItem) + + let llmSubmenuItem = NSMenuItem() + llmSubmenuItem.title = "LLM Refinement" + llmSubmenuItem.submenu = llmSubmenu + menu.addItem(llmSubmenuItem) + + menu.addItem(.separator()) + + // 退出 + let quitItem = NSMenuItem( + title: "Quit", + action: #selector(quit), + keyEquivalent: "q" + ) + quitItem.target = self + menu.addItem(quitItem) + + statusItem.menu = menu + } + + private func updateLLMMenuState() { + llmEnabledItem.state = Config.shared.llmEnabled ? .on : .off + } + + private func updateLanguageMenuState() { + for item in languageMenuItems { + guard let lang = item.representedObject as? RecognitionLanguage else { continue } + item.state = lang == Config.shared.language ? .on : .off + } + // 更新第一项的显示 + if let first = menu.items.first { + first.title = "Language: \(Config.shared.language.displayName)" + } + } + + @objc private func selectLanguage(_ sender: NSMenuItem) { + guard let lang = sender.representedObject as? RecognitionLanguage else { return } + Config.shared.language = lang + updateLanguageMenuState() + print("[Menu] Language switched to: \(lang.displayName)") + } + + @objc private func toggleLLM() { + Config.shared.llmEnabled.toggle() + updateLLMMenuState() + print("[Menu] LLM Refinement: \(Config.shared.llmEnabled ? "ON" : "OFF")") + } + + @objc private func openSettings() { + settingsWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func quit() { + NSApp.terminate(nil) + } +} diff --git a/Sources/Core/AudioLevelMonitor.swift b/Sources/Core/AudioLevelMonitor.swift new file mode 100644 index 0000000..c2e509f --- /dev/null +++ b/Sources/Core/AudioLevelMonitor.swift @@ -0,0 +1,66 @@ +import AVFAudio +import Foundation + +/// 实时音频电平监测,通过 AVAudioEngine 获取麦克风 RMS 电平 +final class AudioLevelMonitor { + + private let engine = AVAudioEngine() + private var isRunning = false + + /// RMS 电平回调,值范围约 0.0 ~ 1.0(正常语音),可能超过 1.0 + var onLevelUpdate: ((Float) -> Void)? + + func start() -> Bool { + guard !isRunning else { return true } + + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + // 安装 tap 获取实时音频缓冲 + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.processAudioBuffer(buffer) + } + + do { + try engine.start() + isRunning = true + print("[AudioLevel] Started") + return true + } catch { + print("[AudioLevel] Failed to start engine: \(error)") + return false + } + } + + func stop() { + guard isRunning else { return } + engine.inputNode.removeTap(onBus: 0) + engine.stop() + isRunning = false + print("[AudioLevel] Stopped") + } + + private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { return } + let frameLength = Int(buffer.frameLength) + let channelCount = Int(buffer.format.channelCount) + + // 计算所有通道的 RMS + var sumSquares: Float = 0 + for channel in 0.. String? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return nil + } + return TISGetInputSourceProperty(source, kTISPropertyInputSourceID) + .flatMap { Unmanaged.fromOpaque($0).takeUnretainedValue() as String } + } + + /// 判断当前输入源是否为 CJK 输入法 + static func isCJKInputSource() -> Bool { + guard let id = currentInputSourceID() else { return false } + let cjkPrefixes = [ + "com.apple.inputmethod.Kotoeri", // 日语 + "com.apple.inputmethod.SCIM", // 简体中文 + "com.apple.inputmethod.TCIM", // 繁体中文 + "com.apple.inputmethod.Korean", // 韩语 + "com.apple.inputmethod.2SetHangul", + "com.apple.inputmethod.3SetHangul", + "com.apple.inputmethod.390Hangul", + "com.apple.inputmethod.GongjinCheong", + "com.apple.inputmethod.HNC" + ] + return cjkPrefixes.contains(where: { id.hasPrefix($0) }) + } + + /// 查找 ASCII 输入源(ABC 键盘) + static func findASCIIInputSource() -> TISInputSource? { + let properties: [CFString: Any] = [ + kTISPropertyInputSourceType: kTISTypeKeyboardLayout as CFString, + kTISPropertyInputSourceIsASCIICapable: true as CFBoolean + ] + guard let list = TISCreateInputSourceList(properties as CFDictionary, false)? + .takeRetainedValue() as? [TISInputSource] else { + return nil + } + // 优先选择 "ABC" 或 "U.S." + for source in list { + if let id = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) + .flatMap({ Unmanaged.fromOpaque($0).takeUnretainedValue() as String }) { + if id == "com.apple.keylayout.ABC" || id == "com.apple.keylayout.US" { + return source + } + } + } + // 回退到第一个 ASCII 能力的输入源 + return list.first + } + + /// 切换到指定输入源并返回原输入源 + static func switchToASCII() -> TISInputSource? { + let original = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() + guard let asciiSource = findASCIIInputSource() else { + return original + } + TISSelectInputSource(asciiSource) + return original + } + + /// 恢复到指定输入源 + static func restore(_ source: TISInputSource?) { + guard let source = source else { return } + TISSelectInputSource(source) + } +} diff --git a/Sources/Core/KeyMonitor.swift b/Sources/Core/KeyMonitor.swift new file mode 100644 index 0000000..99bb0b5 --- /dev/null +++ b/Sources/Core/KeyMonitor.swift @@ -0,0 +1,130 @@ +import Cocoa +import CoreGraphics + +/// 全局 Fn / Globe 键监听器 +final class KeyMonitor { + + // Fn 键标志:CGEventFlags.maskSecondaryFn = 0x00800000 + private static let fnMask: UInt64 = 0x00800000 + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var isFnPressed = false + + var onFnDown: (() -> Void)? + var onFnUp: (() -> Void)? + + func start() -> Bool { + guard eventTap == nil else { return true } + + guard CGPreflightListenEventAccess() else { + CGRequestListenEventAccess() + print("[KeyMonitor] ❌ Accessibility permission not granted") + return false + } + + let eventMask = (1 << CGEventType.flagsChanged.rawValue) + | (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.keyUp.rawValue) + + let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged? in + let monitor = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() + return monitor.handleEvent(proxy: proxy, type: type, event: event) + } + + // 尝试 HID 级 tap(能捕获被系统拦截的 Fn 键) + var tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(eventMask), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + + if tap == nil { + // 回退到会话级 tap + print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap") + tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: CGEventMask(eventMask), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + } + + guard let tap = tap else { + print("[KeyMonitor] ❌ Failed to create event tap") + return false + } + + eventTap = tap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + + print("[KeyMonitor] ✅ Started") + return true + } + + func stop() { + guard let tap = eventTap else { return } + CGEvent.tapEnable(tap: tap, enable: false) + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes) + } + eventTap = nil + runLoopSource = nil + print("[KeyMonitor] Stopped") + } + + private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { + let flags = event.flags.rawValue + let fnInFlags = (flags & Self.fnMask) != 0 + + switch type { + case .flagsChanged: + if fnInFlags && !isFnPressed { + print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))") + return handleFnDown() + } + if !fnInFlags && isFnPressed { + print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))") + return handleFnUp() + } + return Unmanaged.passUnretained(event) + + case .keyDown, .keyUp: + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + if keyCode == 63 && fnInFlags { + return handleFnDown() + } + return Unmanaged.passUnretained(event) + + default: + return Unmanaged.passUnretained(event) + } + } + + private func handleFnDown() -> Unmanaged? { + if !isFnPressed { + isFnPressed = true + DispatchQueue.main.async { [weak self] in + self?.onFnDown?() + } + } + return nil + } + + private func handleFnUp() -> Unmanaged? { + if isFnPressed { + isFnPressed = false + DispatchQueue.main.async { [weak self] in + self?.onFnUp?() + } + } + return nil + } +} diff --git a/Sources/Core/LLMRefiner.swift b/Sources/Core/LLMRefiner.swift new file mode 100644 index 0000000..49a0c4f --- /dev/null +++ b/Sources/Core/LLMRefiner.swift @@ -0,0 +1,97 @@ +import Foundation + +/// 通过 OpenAI 兼容 API 对转录文本进行纠错优化 +final class LLMRefiner { + + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 20 + return URLSession(configuration: config) + }() + + /// 纠错结果 + struct RefinementResult { + let refinedText: String + let wasChanged: Bool + } + + /// 对转录文本进行纠错 + /// - Parameters: + /// - text: 原始转录文本 + /// - config: LLM 配置 + /// - Returns: 纠错结果 + func refine(text: String, config: Config) async throws -> RefinementResult { + let baseURL = config.llmBaseURL + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let url = URL(string: "\(baseURL)/v1/chat/completions")! + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("Bearer \(config.llmAPIKey)", forHTTPHeaderField: "Authorization") + + let systemPrompt = """ + 你是一个语音识别后处理工具。你唯一的任务是修正转录文本中的明显识别错误。规则: + 1. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如: + 「变异」→「编译」、「程序圆」→「程序员」、「配森」→「Python」。 + 请根据上下文判断修正方向,将同音错误还原为正确的词汇。 + 2. 修正被错误转录为中文的英文技术术语。 + 3. 修正极其明显的中文和英文拼写错误(包括错别字、漏字、多字)。 + 4. 修正明显的乱码或无意义字符序列。 + 5. 绝不重写、润色、改进或修改任何看起来正确的内容。 + 6. 如果输入本身正确,请原样返回,不做任何修改。 + 7. 保留所有标点符号、空格、大小写和格式。 + 8. 只返回修正后的文本,不要添加任何解释、前缀、后缀或 Markdown 标记。 + """ + + let messages: [[String: String]] = [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": text] + ] + + let body: [String: Any] = [ + "model": config.llmModel, + "messages": messages, + "max_tokens": 4096, + "thinking": ["type": "disabled"] + ] + + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NSError(domain: "LLMRefiner", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "Unknown" + throw NSError(domain: "LLMRefiner", code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode): \(body)"]) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let refined = message["content"] as? String else { + throw NSError(domain: "LLMRefiner", code: -2, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"]) + } + + let trimmed = refined.trimmingCharacters(in: .whitespacesAndNewlines) + let wasChanged = trimmed != text.trimmingCharacters(in: .whitespacesAndNewlines) + + print("[LLM] Refinement \(wasChanged ? "changed" : "unchanged"): \"\(trimmed.prefix(80))...\"") + return RefinementResult(refinedText: trimmed, wasChanged: wasChanged) + } + + /// 测试连接 + func testConnection(config: Config) async throws -> String { + let testText = "测试连接" + let result = try await refine(text: testText, config: config) + return result.refinedText + } +} diff --git a/Sources/Core/SpeechRecognizer.swift b/Sources/Core/SpeechRecognizer.swift new file mode 100644 index 0000000..03799ed --- /dev/null +++ b/Sources/Core/SpeechRecognizer.swift @@ -0,0 +1,133 @@ +import Foundation +import Speech +import AVFAudio + +/// 流式语音识别,使用 Apple Speech Recognition 框架 +final class SpeechRecognizer: NSObject, SFSpeechRecognizerDelegate { + + private var recognizer: SFSpeechRecognizer? + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + private let engine = AVAudioEngine() + private var isRunning = false + + var onResult: ((String) -> Void)? + var onError: ((Error) -> Void)? + var onStateChange: ((Bool) -> Void)? // true = 开始识别中 + + override init() { + super.init() + } + + /// 请求权限 + func requestAuthorization() async -> Bool { + let status = await withCheckedContinuation { (continuation: CheckedContinuation) in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + return status == .authorized + } + + /// 使用指定语言启动识别 + func start(language: String) async -> Bool { + guard !isRunning else { return true } + + let authorized = await requestAuthorization() + guard authorized else { + onError?(NSError(domain: "SpeechRecognizer", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Speech recognition not authorized"])) + return false + } + + guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: language)), + recognizer.isAvailable else { + let msg = "Speech recognizer unavailable for \(language)" + onError?(NSError(domain: "SpeechRecognizer", code: -2, + userInfo: [NSLocalizedDescriptionKey: msg])) + return false + } + + self.recognizer = recognizer + recognizer.delegate = self + recognizer.defaultTaskHint = .dictation + + // 创建新的请求 + request = SFSpeechAudioBufferRecognitionRequest() + guard let request = request else { return false } + request.shouldReportPartialResults = true + request.addsPunctuation = true + + // 获取音频输入 + let inputNode = engine.inputNode + let format = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.request?.append(buffer) + } + + do { + try engine.start() + } catch { + onError?(error) + return false + } + + // 开始识别任务 + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self = self else { return } + + if let error = error { + DispatchQueue.main.async { + self.onError?(error) + } + return + } + + if let result = result { + // 获取当前最佳转录及未完成的备选 + let text = result.bestTranscription.formattedString + DispatchQueue.main.async { + self.onResult?(text) + } + } + } + + isRunning = true + onStateChange?(true) + print("[Speech] Started recognition for \(language)") + return true + } + + func stop() -> String? { + guard isRunning else { return nil } + isRunning = false + + engine.inputNode.removeTap(onBus: 0) + engine.stop() + + // 结束识别流 + request?.endAudio() + task?.finish() + + // 结果已在回调中处理 + request = nil + task = nil + recognizer = nil + + onStateChange?(false) + print("[Speech] Stopped recognition") + return nil + } + + func cancel() { + task?.cancel() + _ = stop() + } + + // MARK: - SFSpeechRecognizerDelegate + + func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, + availabilityDidChange available: Bool) { + print("[Speech] Availability changed: \(available)") + } +} diff --git a/Sources/Core/TextInjector.swift b/Sources/Core/TextInjector.swift new file mode 100644 index 0000000..a09a2e5 --- /dev/null +++ b/Sources/Core/TextInjector.swift @@ -0,0 +1,105 @@ +import Cocoa +import Carbon + +/// 通过剪贴板备份 → 写入 → Cmd+V 粘贴 → 恢复剪贴板的方式注入文本 +struct TextInjector { + + /// 剪贴板备份结构 + private struct PasteboardBackup { + let items: [[String: Data]] + } + + /// 将文本注入当前焦点输入框(异步完成剪贴板恢复) + @discardableResult + static func inject(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + + // 备份 + let backup = backupPasteboard() + + // 检测输入法 + let isCJK = InputSourceManager.isCJKInputSource() + let originalSource: TISInputSource? = isCJK ? InputSourceManager.switchToASCII() : nil + + if isCJK { + Thread.sleep(forTimeInterval: 0.05) + } + + // 写入目标文本 + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + + // 模拟 Cmd+V(必须在主线程 run loop 中处理) + simulatePaste() + + // 恢复输入法(不阻塞主线程) + if isCJK { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + InputSourceManager.restore(originalSource) + } + } + + // 延迟恢复剪贴板,确保粘贴事件已被目标应用处理 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + restorePasteboard(backup) + } + + print("[Injector] Injected \"\(text.prefix(50))...\" (\(text.count) chars)") + return true + } + + // MARK: - 剪贴板备份/恢复 + + private static func backupPasteboard() -> PasteboardBackup { + let pb = NSPasteboard.general + guard let items = pb.pasteboardItems, !items.isEmpty else { + var fallback: [[String: Data]] = [] + if let str = pb.string(forType: .string), + let data = str.data(using: .utf8) { + fallback = [["public.utf8-plain-text": data]] + } + return PasteboardBackup(items: fallback) + } + + let backedItems: [[String: Data]] = items.compactMap { item in + var typeData: [String: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + typeData[type.rawValue] = data + } + } + return typeData.isEmpty ? nil : typeData + } + + return PasteboardBackup(items: backedItems) + } + + private static func restorePasteboard(_ backup: PasteboardBackup) { + let pb = NSPasteboard.general + pb.clearContents() + + for typeData in backup.items { + let newItem = NSPasteboardItem() + for (type, data) in typeData { + newItem.setData(data, forType: NSPasteboard.PasteboardType(type)) + } + pb.writeObjects([newItem]) + } + } + + // MARK: - 模拟粘贴 + + private static func simulatePaste() { + let source = CGEventSource(stateID: .combinedSessionState) + + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) + keyDown?.flags = .maskCommand + keyDown?.post(tap: .cghidEventTap) + + usleep(10_000) + + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) + keyUp?.flags = .maskCommand + keyUp?.post(tap: .cghidEventTap) + } +} diff --git a/Sources/Resources/Info.plist b/Sources/Resources/Info.plist new file mode 100644 index 0000000..7fcde88 --- /dev/null +++ b/Sources/Resources/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + VoiceInput + CFBundleIdentifier + com.voiceinput.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + VoiceInput + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSMicrophoneUsageDescription + Voice Input needs microphone access to record your speech for transcription. + NSSpeechRecognitionUsageDescription + Voice Input needs speech recognition access to transcribe your voice into text. + + diff --git a/Sources/UI/RecordingPanel.swift b/Sources/UI/RecordingPanel.swift new file mode 100644 index 0000000..d8a5c56 --- /dev/null +++ b/Sources/UI/RecordingPanel.swift @@ -0,0 +1,254 @@ +import Cocoa + +/// 录音期间屏幕底部居中的胶囊形浮动窗口 +final class RecordingPanel: NSPanel { + + // MARK: - 子视图 + let waveformView: WaveformView + private let textLabel: NSTextField + private let containerStack: NSStackView + + // 文本宽度约束 + private var textWidthConstraint: NSLayoutConstraint? + + // 窗口宽度常量 + private let minPanelWidth: CGFloat = 180 + private let maxPanelWidth: CGFloat = 680 + private let panelHeight: CGFloat = 56 + private let cornerRadius: CGFloat = 28 + private let waveformSize = NSSize(width: 44, height: 32) + private let marginAboveDock: CGFloat = 12 + + init() { + // Waveform + waveformView = WaveformView(frame: NSRect(origin: .zero, size: waveformSize)) + waveformView.translatesAutoresizingMaskIntoConstraints = false + waveformView.wantsLayer = true + + // Label + textLabel = NSTextField(labelWithString: "") + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.font = .systemFont(ofSize: 16, weight: .medium) + textLabel.textColor = .white + textLabel.alignment = .left + textLabel.lineBreakMode = .byTruncatingTail + textLabel.maximumNumberOfLines = 1 + textLabel.backgroundColor = .clear + textLabel.isBezeled = false + textLabel.drawsBackground = false + + // Stack + containerStack = NSStackView(views: [waveformView, textLabel]) + containerStack.translatesAutoresizingMaskIntoConstraints = false + containerStack.orientation = .horizontal + containerStack.alignment = .centerY + containerStack.spacing = 12 + containerStack.edgeInsets = NSEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + + // Panel 配置 + super.init( + contentRect: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + configurePanel() + configureVisualEffect() + setupConstraints() + } + + private func configurePanel() { + isFloatingPanel = true + becomesKeyOnlyIfNeeded = true + isOpaque = false + hasShadow = false + level = .floating + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + ignoresMouseEvents = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovableByWindowBackground = false + + // 彻底去除边框 + backgroundColor = .clear + isOpaque = false + } + + private func configureVisualEffect() { + let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight)) + visualEffect.material = .hudWindow + visualEffect.blendingMode = .behindWindow + visualEffect.state = .active + visualEffect.wantsLayer = true + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = true + visualEffect.layer?.borderWidth = 0 + visualEffect.layer?.borderColor = NSColor.clear.cgColor + visualEffect.autoresizingMask = [.width, .height] + + // 替换 contentView + contentView = visualEffect + visualEffect.addSubview(containerStack) + } + + private func setupConstraints() { + guard let content = contentView else { return } + + NSLayoutConstraint.activate([ + // Waveform 固定尺寸 + waveformView.widthAnchor.constraint(equalToConstant: waveformSize.width), + waveformView.heightAnchor.constraint(equalToConstant: waveformSize.height), + + // Container 填充 + containerStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), + containerStack.centerYAnchor.constraint(equalTo: content.centerYAnchor), + containerStack.heightAnchor.constraint(equalToConstant: panelHeight) + ]) + + // 文本弹性宽度 + textWidthConstraint = textLabel.widthAnchor.constraint(equalToConstant: 160) + textWidthConstraint?.isActive = true + } + + /// 更新转录文本 + func updateText(_ text: String) { + textLabel.stringValue = text + + // 计算所需宽度 + let maxTextWidth = maxPanelWidth - waveformSize.width - 40 // 40 = spacing + insets + let attributes: [NSAttributedString.Key: Any] = [.font: textLabel.font!] + let textSize = (text as NSString).size(withAttributes: attributes) + let requiredTextWidth = min(max(textSize.width + 10, 160), maxTextWidth) + let targetPanelWidth = requiredTextWidth + waveformSize.width + 44 + + // 动画更新宽度 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.25 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + ctx.allowsImplicitAnimation = true + + textWidthConstraint?.animator().constant = requiredTextWidth + + let frame = currentScreenFrame() + let y = bottomY(for: frame) + let newFrame = NSRect( + x: (frame.width - targetPanelWidth) / 2, + y: y, + width: targetPanelWidth, + height: panelHeight + ) + animator().setFrame(newFrame, display: true) + } + } + + /// 显示面板(弹簧动画) + func showAnimated() { + let frame = currentScreenFrame() + let y = bottomY(for: frame) + + let initialFrame = NSRect( + x: (frame.width - minPanelWidth) / 2, + y: y, + width: minPanelWidth, + height: panelHeight + ) + + // 初始状态:缩小且透明 + setFrame(initialFrame, display: false) + alphaValue = 0 + + // 出场 + makeKeyAndOrderFront(nil) + + // 弹簧动画 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.35 + ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.0, 0.5, 0.3, 1.5) // 弹簧曲线 + animator().alphaValue = 1.0 + } + // 弹簧效果通过 CATransaction 模拟 + let spring = CASpringAnimation(keyPath: "transform.scale") + spring.fromValue = 0.85 + spring.toValue = 1.0 + spring.duration = 0.35 + spring.damping = 12 + spring.initialVelocity = 0 + spring.fillMode = .backwards + contentView?.layer?.add(spring, forKey: "showSpring") + } + + /// 隐藏面板(缩放动画) + func hideAnimated(completion: (() -> Void)? = nil) { + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.22 + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + animator().alphaValue = 0 + + // 缩放 + let scale = CABasicAnimation(keyPath: "transform.scale") + scale.fromValue = 1.0 + scale.toValue = 0.9 + scale.duration = 0.22 + scale.timingFunction = CAMediaTimingFunction(name: .easeIn) + scale.fillMode = .forwards + scale.isRemovedOnCompletion = false + contentView?.layer?.add(scale, forKey: "hideScale") + + }, completionHandler: { + self.orderOut(nil) + self.contentView?.layer?.removeAnimation(forKey: "hideScale") + self.textLabel.stringValue = "" + self.textWidthConstraint?.animator().constant = 160 + completion?() + }) + } + + /// 显示 Refining 状态(弹窗自适应缩窄) + func showRefining() { + textLabel.stringValue = "Refining..." + let refiningTextWidth: CGFloat = 120 + let targetWidth: CGFloat = refiningTextWidth + waveformSize.width + 44 + + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.2 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + ctx.allowsImplicitAnimation = true + + textWidthConstraint?.animator().constant = refiningTextWidth + + let frame = currentScreenFrame() + let y = bottomY(for: frame) + let newFrame = NSRect( + x: (frame.width - targetWidth) / 2, + y: y, + width: targetWidth, + height: panelHeight + ) + animator().setFrame(newFrame, display: true) + } + + waveformView.updateLevel(0.3) + } + + // 重写:允许在非 active 状态响应 + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + // MARK: - 屏幕坐标 + + private func currentScreenFrame() -> NSRect { + // 优先使用鼠标所在屏幕,否则主屏幕 + let mouseLocation = NSEvent.mouseLocation + for screen in NSScreen.screens { + if screen.frame.contains(mouseLocation) { + return screen.visibleFrame + } + } + return NSScreen.main?.visibleFrame ?? .zero + } + + private func bottomY(for screenFrame: NSRect) -> CGFloat { + screenFrame.minY + marginAboveDock + } +} diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift new file mode 100644 index 0000000..8cc5557 --- /dev/null +++ b/Sources/UI/SettingsWindow.swift @@ -0,0 +1,207 @@ +import Cocoa + +/// LLM 设置窗口 +final class SettingsWindow: NSWindow { + + private let baseURLField: NSTextField + private let apiKeyField: NSSecureTextField + private let modelField: NSTextField + private let testButton: NSButton + private let saveButton: NSButton + private let statusLabel: NSTextField + + init() { + // 表单字段 + baseURLField = NSTextField(frame: .zero) + apiKeyField = NSSecureTextField(frame: .zero) + modelField = NSTextField(frame: .zero) + testButton = NSButton(title: "Test", target: nil, action: nil) + saveButton = NSButton(title: "Save", target: nil, action: nil) + statusLabel = NSTextField(labelWithString: "") + + let windowRect = NSRect(x: 0, y: 0, width: 420, height: 280) + super.init( + contentRect: windowRect, + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + + configureWindow() + buildUI() + loadCurrentConfig() + } + + private func configureWindow() { + title = "LLM Settings" + isReleasedWhenClosed = false + center() + } + + private func buildUI() { + guard let content = contentView else { return } + + // 标签 + func makeLabel(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .secondaryLabelColor + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + func makeField(_ placeholder: String, isSecure: Bool = false) -> NSTextField { + let field = isSecure ? NSSecureTextField(frame: .zero) : NSTextField(frame: .zero) + field.placeholderString = placeholder + field.font = .systemFont(ofSize: 13) + field.bezelStyle = .roundedBezel + field.translatesAutoresizingMaskIntoConstraints = false + return field + } + + let baseURLLabel = makeLabel("API Base URL:") + let apiKeyLabel = makeLabel("API Key:") + let modelLabel = makeLabel("Model:") + + // 使用实例字段 + baseURLField.placeholderString = "https://api.openai.com" + baseURLField.font = .systemFont(ofSize: 13) + baseURLField.bezelStyle = .roundedBezel + baseURLField.translatesAutoresizingMaskIntoConstraints = false + + apiKeyField.placeholderString = "sk-..." + apiKeyField.font = .systemFont(ofSize: 13) + apiKeyField.bezelStyle = .roundedBezel + apiKeyField.translatesAutoresizingMaskIntoConstraints = false + + modelField.placeholderString = "gpt-3.5-turbo" + modelField.font = .systemFont(ofSize: 13) + modelField.bezelStyle = .roundedBezel + modelField.translatesAutoresizingMaskIntoConstraints = false + + testButton.bezelStyle = .rounded + testButton.translatesAutoresizingMaskIntoConstraints = false + testButton.target = self + testButton.action = #selector(testConnection) + + saveButton.bezelStyle = .rounded + saveButton.translatesAutoresizingMaskIntoConstraints = false + saveButton.keyEquivalent = "\r" + saveButton.target = self + saveButton.action = #selector(saveConfig) + + statusLabel.font = .systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.alignment = .center + statusLabel.maximumNumberOfLines = 2 + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + // 布局 + let grid = NSGridView(views: [ + [baseURLLabel, baseURLField], + [apiKeyLabel, apiKeyField], + [modelLabel, modelField] + ]) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.column(at: 0).xPlacement = .trailing + grid.column(at: 1).width = 260 + grid.rowSpacing = 12 + grid.columnSpacing = 8 + + let buttonStack = NSStackView(views: [testButton, saveButton]) + buttonStack.translatesAutoresizingMaskIntoConstraints = false + buttonStack.orientation = .horizontal + buttonStack.spacing = 12 + buttonStack.distribution = .fillEqually + + content.addSubview(grid) + content.addSubview(buttonStack) + content.addSubview(statusLabel) + + NSLayoutConstraint.activate([ + grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), + grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 20), + buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), + buttonStack.widthAnchor.constraint(equalToConstant: 220), + + statusLabel.topAnchor.constraint(equalTo: buttonStack.bottomAnchor, constant: 12), + statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor), + statusLabel.widthAnchor.constraint(equalToConstant: 380) + ]) + } + + private func loadCurrentConfig() { + baseURLField.stringValue = Config.shared.llmBaseURL + apiKeyField.stringValue = Config.shared.llmAPIKey + modelField.stringValue = Config.shared.llmModel + } + + @objc private func testConnection() { + // 使用输入框当前值进行测试 + + statusLabel.stringValue = "Testing..." + statusLabel.textColor = .systemBlue + + testButton.isEnabled = false + saveButton.isEnabled = false + + let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let key = apiKeyField.stringValue + let model = modelField.stringValue + + guard !url.isEmpty, !key.isEmpty, !model.isEmpty else { + statusLabel.stringValue = "Please fill all fields" + statusLabel.textColor = .systemRed + testButton.isEnabled = true + saveButton.isEnabled = true + return + } + + Task { @MainActor in + let refiner = LLMRefiner() + // 临时写入配置 + let original = (Config.shared.llmBaseURL, Config.shared.llmAPIKey, Config.shared.llmModel) + Config.shared.llmBaseURL = url + Config.shared.llmAPIKey = key + Config.shared.llmModel = model + + do { + let result = try await refiner.testConnection(config: Config.shared) + statusLabel.stringValue = "✅ Connected! Response: \(result)" + statusLabel.textColor = .systemGreen + } catch { + statusLabel.stringValue = "❌ Error: \(error.localizedDescription)" + statusLabel.textColor = .systemRed + } + + // 恢复 + Config.shared.llmBaseURL = original.0 + Config.shared.llmAPIKey = original.1 + Config.shared.llmModel = original.2 + + testButton.isEnabled = true + saveButton.isEnabled = true + } + } + + @objc private func saveConfig() { + let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let key = apiKeyField.stringValue + let model = modelField.stringValue + + Config.shared.llmBaseURL = url + Config.shared.llmAPIKey = key + Config.shared.llmModel = model + + statusLabel.stringValue = "✅ Saved" + statusLabel.textColor = .systemGreen + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.statusLabel.stringValue = "" + } + } +} diff --git a/Sources/UI/WaveformView.swift b/Sources/UI/WaveformView.swift new file mode 100644 index 0000000..22ce643 --- /dev/null +++ b/Sources/UI/WaveformView.swift @@ -0,0 +1,100 @@ +import Cocoa + +/// 5 个球形波动画,由实时音频 RMS 电平驱动 +final class WaveformView: NSView { + + private let weights: [Float] = [0.5, 0.8, 1.0, 0.75, 0.55] + private var dots: [NSView] = [] + private var smoothedLevels: [Float] = [0, 0, 0, 0, 0] + private var decayTimer: Timer? + private var rawLevel: Float = 0 + + private let dotSize: CGFloat = 4 + private let maxExtra: CGFloat = 28 + private let spacing: CGFloat = 6 + private let silenceThreshold: Float = 0.02 + + override init(frame: NSRect) { + super.init(frame: frame) + setupDots() + wantsLayer = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupDots() { + dots.forEach { $0.removeFromSuperview() } + dots.removeAll() + + let totalWidth = CGFloat(weights.count) * dotSize + CGFloat(weights.count - 1) * spacing + let startX = (bounds.width - totalWidth) / 2 + + for (index, _) in weights.enumerated() { + let x = startX + CGFloat(index) * (dotSize + spacing) + let dot = NSView(frame: NSRect(x: x, y: 0, width: dotSize, height: dotSize)) + dot.wantsLayer = true + dot.layer?.cornerRadius = dotSize / 2 + dot.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.9).cgColor + addSubview(dot) + dots.append(dot) + } + + decayTimer?.invalidate() + decayTimer = Timer.scheduledTimer(withTimeInterval: 0.04, repeats: true) { [weak self] _ in + self?.updateDecay() + } + } + + func updateLevel(_ level: Float) { + rawLevel = level + for i in 0.. smoothedLevels[i] { + smoothedLevels[i] = smoothedLevels[i] * 0.55 + target * 0.45 + } + } + applyLevels() + } + + private func updateDecay() { + for i in 0.. "$CONTENTS_DIR/PkgInfo" + +echo "✅ App bundle created: $BUNDLE_DIR"