feat: macOS 菜单栏语音输入应用初始版本
- CGEvent cghidEventTap 全局监听 Fn 键 - Apple Speech 流式语音识别(zh-CN 默认) - 实时音频 RMS 电平驱动波形动画 - NSPanel 胶囊浮动窗口 - 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换 - OpenAI 兼容 LLM 纠错(DeepSeek 预设) - LSUIElement 菜单栏运行 - SPM + Makefile 构建
This commit is contained in:
commit
1d9c9d5fa0
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
*.app
|
||||||
84
Makefile
Normal file
84
Makefile
Normal file
@ -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 '<?xml version="1.0" encoding="UTF-8"?>' > $(ENTITLEMENTS)
|
||||||
|
@echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' >> $(ENTITLEMENTS)
|
||||||
|
@echo '<plist version="1.0">' >> $(ENTITLEMENTS)
|
||||||
|
@echo '<dict>' >> $(ENTITLEMENTS)
|
||||||
|
@echo ' <key>com.apple.security.device.audio-input</key>' >> $(ENTITLEMENTS)
|
||||||
|
@echo ' <true/>' >> $(ENTITLEMENTS)
|
||||||
|
@echo ' <key>com.apple.security.cs.disable-library-validation</key>' >> $(ENTITLEMENTS)
|
||||||
|
@echo ' <true/>' >> $(ENTITLEMENTS)
|
||||||
|
@echo '</dict>' >> $(ENTITLEMENTS)
|
||||||
|
@echo '</plist>' >> $(ENTITLEMENTS)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Codesign (ad-hoc)
|
||||||
|
# ============================================================
|
||||||
|
sign: release
|
||||||
|
@echo "🔏 Signing..."
|
||||||
|
codesign --force --deep --sign - $(APP_BUNDLE)
|
||||||
|
@echo "✅ Signed (ad-hoc)"
|
||||||
21
Package.swift
Normal file
21
Package.swift
Normal file
@ -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")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
159
Sources/App/AppDelegate.swift
Normal file
159
Sources/App/AppDelegate.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
142
Sources/App/MenuBarController.swift
Normal file
142
Sources/App/MenuBarController.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Sources/Core/AudioLevelMonitor.swift
Normal file
66
Sources/Core/AudioLevelMonitor.swift
Normal file
@ -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..<channelCount {
|
||||||
|
let samples = channelData[channel]
|
||||||
|
for i in 0..<frameLength {
|
||||||
|
let sample = samples[i]
|
||||||
|
sumSquares += sample * sample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rms = sqrt(sumSquares / Float(frameLength * channelCount))
|
||||||
|
|
||||||
|
// 放大约 15 倍,使正常语音峰值接近 1.0
|
||||||
|
let scaled = min(rms * 15.0, 1.5)
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onLevelUpdate?(scaled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
Sources/Core/InputSourceManager.swift
Normal file
71
Sources/Core/InputSourceManager.swift
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import Carbon
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 管理输入法切换,用于在粘贴前临时切换到 ASCII 输入源
|
||||||
|
struct InputSourceManager {
|
||||||
|
|
||||||
|
/// 获取当前输入源 ID
|
||||||
|
static func currentInputSourceID() -> String? {
|
||||||
|
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return TISGetInputSourceProperty(source, kTISPropertyInputSourceID)
|
||||||
|
.flatMap { Unmanaged<CFString>.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<CFString>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
Sources/Core/KeyMonitor.swift
Normal file
130
Sources/Core/KeyMonitor.swift
Normal file
@ -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<CGEvent>? in
|
||||||
|
let monitor = Unmanaged<KeyMonitor>.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<CGEvent>? {
|
||||||
|
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<CGEvent>? {
|
||||||
|
if !isFnPressed {
|
||||||
|
isFnPressed = true
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onFnDown?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFnUp() -> Unmanaged<CGEvent>? {
|
||||||
|
if isFnPressed {
|
||||||
|
isFnPressed = false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onFnUp?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Sources/Core/LLMRefiner.swift
Normal file
97
Sources/Core/LLMRefiner.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Sources/Core/SpeechRecognizer.swift
Normal file
133
Sources/Core/SpeechRecognizer.swift
Normal file
@ -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<SFSpeechRecognizerAuthorizationStatus, Never>) 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
105
Sources/Core/TextInjector.swift
Normal file
105
Sources/Core/TextInjector.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Sources/Resources/Info.plist
Normal file
30
Sources/Resources/Info.plist
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>zh_CN</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>VoiceInput</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.voiceinput.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>VoiceInput</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Voice Input needs microphone access to record your speech for transcription.</string>
|
||||||
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
<string>Voice Input needs speech recognition access to transcribe your voice into text.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
254
Sources/UI/RecordingPanel.swift
Normal file
254
Sources/UI/RecordingPanel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
207
Sources/UI/SettingsWindow.swift
Normal file
207
Sources/UI/SettingsWindow.swift
Normal file
@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Sources/UI/WaveformView.swift
Normal file
100
Sources/UI/WaveformView.swift
Normal file
@ -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..<weights.count {
|
||||||
|
let target = level * weights[i]
|
||||||
|
if target > smoothedLevels[i] {
|
||||||
|
smoothedLevels[i] = smoothedLevels[i] * 0.55 + target * 0.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyLevels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDecay() {
|
||||||
|
for i in 0..<weights.count {
|
||||||
|
let target = rawLevel * weights[i]
|
||||||
|
if target < smoothedLevels[i] {
|
||||||
|
smoothedLevels[i] = smoothedLevels[i] * 0.82 + target * 0.18
|
||||||
|
}
|
||||||
|
if smoothedLevels[i] < silenceThreshold {
|
||||||
|
smoothedLevels[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyLevels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLevels() {
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setAnimationDuration(0.06) // 快速平滑过渡
|
||||||
|
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
|
||||||
|
|
||||||
|
for (i, dot) in dots.enumerated() {
|
||||||
|
let level = smoothedLevels[i]
|
||||||
|
let extra = CGFloat(level) * maxExtra
|
||||||
|
let height = dotSize + extra
|
||||||
|
let y = (bounds.height - height) / 2
|
||||||
|
|
||||||
|
dot.frame = NSRect(x: dot.frame.origin.x, y: y, width: dotSize, height: height)
|
||||||
|
dot.layer?.cornerRadius = min(dotSize, height) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
setupDots()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
decayTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Sources/Utils/Config.swift
Normal file
70
Sources/Utils/Config.swift
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum RecognitionLanguage: String, CaseIterable {
|
||||||
|
case chineseSimplified = "zh-CN"
|
||||||
|
case english = "en-US"
|
||||||
|
case chineseTraditional = "zh-TW"
|
||||||
|
case japanese = "ja-JP"
|
||||||
|
case korean = "ko-KR"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .chineseSimplified: return "简体中文"
|
||||||
|
case .english: return "English"
|
||||||
|
case .chineseTraditional: return "繁體中文"
|
||||||
|
case .japanese: return "日本語"
|
||||||
|
case .korean: return "한국어"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
static var shared = Config()
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let language = "recognition_language"
|
||||||
|
static let llmEnabled = "llm_enabled"
|
||||||
|
static let llmBaseURL = "llm_base_url"
|
||||||
|
static let llmAPIKey = "llm_api_key"
|
||||||
|
static let llmModel = "llm_model"
|
||||||
|
}
|
||||||
|
|
||||||
|
var language: RecognitionLanguage {
|
||||||
|
get {
|
||||||
|
guard let raw = defaults.string(forKey: Keys.language),
|
||||||
|
let lang = RecognitionLanguage(rawValue: raw) else {
|
||||||
|
return .chineseSimplified
|
||||||
|
}
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
defaults.set(newValue.rawValue, forKey: Keys.language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var llmEnabled: Bool {
|
||||||
|
get { defaults.bool(forKey: Keys.llmEnabled) }
|
||||||
|
set { defaults.set(newValue, forKey: Keys.llmEnabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var llmBaseURL: String {
|
||||||
|
get { defaults.string(forKey: Keys.llmBaseURL) ?? "https://api.deepseek.com" }
|
||||||
|
set { defaults.set(newValue, forKey: Keys.llmBaseURL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var llmAPIKey: String {
|
||||||
|
get { defaults.string(forKey: Keys.llmAPIKey) ?? "sk-3457fbc33f0b4aefb2ce1d3101bb2341" }
|
||||||
|
set { defaults.set(newValue, forKey: Keys.llmAPIKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var llmModel: String {
|
||||||
|
get { defaults.string(forKey: Keys.llmModel) ?? "deepseek-v4-flash" }
|
||||||
|
set { defaults.set(newValue, forKey: Keys.llmModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLLMConfigured: Bool {
|
||||||
|
!llmBaseURL.isEmpty && !llmAPIKey.isEmpty && !llmModel.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Sources/main.swift
Normal file
8
Sources/main.swift
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Cocoa
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
33
scripts/create_app_bundle.sh
Executable file
33
scripts/create_app_bundle.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# create_app_bundle.sh - 将 SPM 构建产物打包为 .app bundle
|
||||||
|
# Usage: ./scripts/create_app_bundle.sh [debug|release]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUILD_MODE="${1:-debug}"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BUILD_DIR="$PROJECT_DIR/.build"
|
||||||
|
BUNDLE_DIR="$BUILD_DIR/$BUILD_MODE/VoiceInput.app"
|
||||||
|
EXEC_SRC="$BUILD_DIR/$BUILD_MODE/VoiceInput"
|
||||||
|
CONTENTS_DIR="$BUNDLE_DIR/Contents"
|
||||||
|
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||||
|
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||||
|
|
||||||
|
echo "📦 Creating app bundle at $BUNDLE_DIR..."
|
||||||
|
|
||||||
|
# 清空并创建目录结构
|
||||||
|
rm -rf "$BUNDLE_DIR"
|
||||||
|
mkdir -p "$MACOS_DIR"
|
||||||
|
mkdir -p "$RESOURCES_DIR"
|
||||||
|
|
||||||
|
# 复制可执行文件
|
||||||
|
cp "$EXEC_SRC" "$MACOS_DIR/VoiceInput"
|
||||||
|
chmod +x "$MACOS_DIR/VoiceInput"
|
||||||
|
|
||||||
|
# 复制 Info.plist
|
||||||
|
cp "$PROJECT_DIR/Sources/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
|
||||||
|
|
||||||
|
# 创建 PkgInfo
|
||||||
|
echo -n 'APPL????' > "$CONTENTS_DIR/PkgInfo"
|
||||||
|
|
||||||
|
echo "✅ App bundle created: $BUNDLE_DIR"
|
||||||
Loading…
Reference in New Issue
Block a user