commit f39b3f91372fc04891f298e29265230e923daac9 Author: eccstartup Date: Fri Oct 23 10:13:38 2026 +0800 feat: build TinyTTS local macOS menu bar dictation app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d63e6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +dist/ +.build/ +.workbuddy/ +PLAN.md +TESTING.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..decd571 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TinyTTS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2980bce --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "TinyTTS", + platforms: [.macOS("26.0")], + products: [ + .executable(name: "TinyTTS", targets: ["TinyTTS"]) + ], + targets: [ + .executableTarget( + name: "TinyTTS", + path: "Sources/TinyTTS", + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .testTarget(name: "TinyTTSTests", dependencies: ["TinyTTS"], path: "Tests/TinyTTSTests", swiftSettings: [.swiftLanguageMode(.v5)]) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..231a680 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +

+ TinyTTS logo +

+ +# TinyTTS + +一个 Typeless 风格的开源精简版 macOS 语音输入工具。常驻右上角菜单栏,按一下右 Option 开始说话,再按一下,把文字写到当前输入框。 + +原生 Swift + SwiftUI,无第三方依赖,无账号、API key 或订阅。TinyTTS 名称沿用工程命名,功能是语音转文字(STT)。本项目与 Typeless 无隶属关系。 + +## 默认交互 + +- **右 Option**:按一次开始,再按一次结束;设置中可重新绑定,也可改为按住说话。 +- **Esc**:取消当前录音或收尾,丢弃本次文字。 +- **固定中文(普通话)**:使用系统本地模型;夹杂英文的效果取决于模型,不做自动语言切换。 +- **保留识别标点,自动补句号**:不删除口头语、不改写、不合并内部空格;可关闭补句号。 +- 实时字幕和音量条,不抢走输入焦点;可关闭字幕或固定到屏幕底部。 +- 默认连续 12 秒无声自动结束,可在设置中关闭。 + +菜单栏展示待机、准备、录音和处理中状态。录音时图标呼吸,处理时旋转;菜单里可开始/结束、取消、查看权限和模型状态、复制上次结果、打开设置或退出。待机时不运行图标动画。 + +## 环境与构建 + +需要 macOS 26+、包含 macOS 26+ SDK 的 Xcode / Swift 工具链。本次验证平台为 Apple Silicon;Intel 尚未验证。系统语音模型单独安装,不包含在 app 包体中。 + +```bash +./scripts/test.sh +./scripts/build.sh +open dist/TinyTTS.app +``` + +产物为 `dist/TinyTTS.app`,构建脚本会组装图标、Info.plist 并验证代码签名。无需 `.xcodeproj`,也无需 `swift run`。请从 `.app` 启动,以正确关联系统权限。 + +日常使用时退出旧版本,将 app 放入 `/Applications` 后再启动;在通用设置中开启登录启动。系统若要求批准登录项,需要到系统设置确认。 + +### 固定签名身份 + +脚本依次选择 `TINYTTS_SIGN_IDENTITY`、可用 Apple Development 身份、TinyTTS Dev 身份,否则使用 ad-hoc 签名并发出提示。 + +```bash +# 已有签名身份时 +TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh + +# 没有身份时,可在自己的终端运行,导入本地开发证书 +./scripts/setup-signing.sh +``` + +自签名证书可能需要在「钥匙串访问」中设置信任后才能被 codesign 使用。请自行操作系统信任与密码提示。固定身份、bundle id 和安装路径有助于保持辅助功能授权;ad-hoc 重建通常需要重新授权。脚本不提供 Developer ID 公证,公开二进制分发还需独立完成签名与公证。 + +## 首次使用 + +1. 启动 app,允许麦克风,在「隐私与安全性 → 辅助功能」中允许 TinyTTS。若系统提示输入监控,也需在相应页面授权。辅助功能授权后会自动重试热键。 +2. 打开「设置与测试语音 → 语音」,检查模型状态。首次下载需要网络,下载完成后的识别在本机进行;失败时可重试。 +3. 点「开始测试」检查实时识别,结束后查看结果。此模式不向任何应用写入文字。 +4. 回到目标输入框,按右 Option,说话,再按一次结束。 + +设置有通用、快捷键、语音、关于四个页面。语音页可在自动、本地 SpeechTranscriber、本地 DictationTranscriber 和旧版本地识别间选择。自动模式优先 SpeechTranscriber,不支持时降级;旧版强制 `requiresOnDeviceRecognition`,额外需要系统语音识别权限,缺少本地能力时不会转为云端识别。 + +## 文字写入与隐私 + +优先通过辅助功能替换当前选区;不支持 AX 写入时回退剪贴板和 ⌘V。剪贴板会保存所有可读取的数据类型,在没有被其他操作改动时恢复。不同应用的粘贴速度和 AX 行为可能不同;⌘V 事件发出不代表目标已经确认接收,请确认输入框结果。必要时从菜单「复制上次识别结果」找回文字。 + +录音期间切换前台应用,或结束时进入安全输入模式,会阻止自动写入并将文字保留在剪贴板。密码等 Secure Input 场景不触发录音。取消中的会话不会回写结果。 + +不保存音频文件、不上传录音,不记录识别原文;本次结果只在进程内保留,退出后清除。设置使用 UserDefaults。诊断日志位于 `~/Library/Logs/TinyTTS.log`,记录错误、字符数和时间,超过约 1 MB 轮换为 `.log.1`;系统崩溃报告位于 macOS 的 DiagnosticReports 目录。模型下载由 Apple 系统服务完成。 + +## 测试 + +```bash +./scripts/test.sh +./scripts/build.sh + +# 用真实本地模型识别系统合成语音,需先安装模型 +./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest +./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --dictation +``` + +自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。真实识别与跨应用上屏仍需在系统语音服务正常时完成验收。 + +计划中的首字 <300 ms、结束到上屏 <500 ms、空闲 CPU <1%、内存 <80 MB 是验收目标,不是跨设备保证。首次加载模型、说话前静音、系统调度和应用粘贴处理都会影响数据。日志中的 `release to insertion dispatch` 仅测到写入请求发出。 + +## 开发 + +``` +Sources/TinyTTS/ + App/ 应用生命周期与听写状态机 + Hotkey/ 全局按键与改绑 + Speech/ 模型、预热、本地识别与音频采集 + Text/ 选区写入、剪贴板回退、标点 + UI/ 菜单栏、非激活字幕、四页设置 + Support/ 偏好、权限、登录启动、日志 +Tests/ Swift Testing 回归 +Resources/ 图标与 Info.plist +scripts/ 构建、测试、签名、图标生成 +``` + +重建 logo:`swift scripts/make-icon.swift Resources`。图标使用原生 AppKit 绘图,可重复生成 16–1024 px PNG 和 ICNS,深靛蓝底上的麦克风以文字条镂空。小尺寸简化细节以保证辨识度。 + +## 许可证 + +[MIT](LICENSE)。系统语音框架与下载模型由 Apple 提供,不随本项目再分发。 diff --git a/Resources/AppIcon-1024.png b/Resources/AppIcon-1024.png new file mode 100644 index 0000000..a037296 Binary files /dev/null and b/Resources/AppIcon-1024.png differ diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..1faeb55 Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/AppIcon.iconset/icon_128x128.png b/Resources/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000..eda8f5e Binary files /dev/null and b/Resources/AppIcon.iconset/icon_128x128.png differ diff --git a/Resources/AppIcon.iconset/icon_128x128@2x.png b/Resources/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..57ed0eb Binary files /dev/null and b/Resources/AppIcon.iconset/icon_128x128@2x.png differ diff --git a/Resources/AppIcon.iconset/icon_16x16.png b/Resources/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000..2778c55 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_16x16.png differ diff --git a/Resources/AppIcon.iconset/icon_16x16@2x.png b/Resources/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..4eecef2 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_16x16@2x.png differ diff --git a/Resources/AppIcon.iconset/icon_256x256.png b/Resources/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000..57ed0eb Binary files /dev/null and b/Resources/AppIcon.iconset/icon_256x256.png differ diff --git a/Resources/AppIcon.iconset/icon_256x256@2x.png b/Resources/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..1e6ebc2 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_256x256@2x.png differ diff --git a/Resources/AppIcon.iconset/icon_32x32.png b/Resources/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000..4eecef2 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_32x32.png differ diff --git a/Resources/AppIcon.iconset/icon_32x32@2x.png b/Resources/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..e6bbf1a Binary files /dev/null and b/Resources/AppIcon.iconset/icon_32x32@2x.png differ diff --git a/Resources/AppIcon.iconset/icon_512x512.png b/Resources/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000..1e6ebc2 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_512x512.png differ diff --git a/Resources/AppIcon.iconset/icon_512x512@2x.png b/Resources/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..a037296 Binary files /dev/null and b/Resources/AppIcon.iconset/icon_512x512@2x.png differ diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..8149ebc --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + TinyTTS + CFBundleIdentifier + com.eccstartup.tinystt + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + AppIcon + CFBundleName + TinyTTS + CFBundleDisplayName + TinyTTS + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 26.0 + LSUIElement + + NSHighResolutionCapable + + NSMicrophoneUsageDescription + TinyTTS 需要用麦克风把你说的话转成文字。 + NSSpeechRecognitionUsageDescription + TinyTTS 在本机完成语音识别,音频不会上传到任何服务器。 + NSHumanReadableCopyright + TinyTTS + + diff --git a/Sources/TinyTTS/App/AppDelegate.swift b/Sources/TinyTTS/App/AppDelegate.swift new file mode 100644 index 0000000..0d7b709 --- /dev/null +++ b/Sources/TinyTTS/App/AppDelegate.swift @@ -0,0 +1,144 @@ +import AppKit + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private let prefs = Prefs.shared + private let controller = DictationController.shared + private let hud = HUDController() + private let settings = SettingsWindowController() + + private var status: StatusItemController! + private var hotkey: HotkeyMonitor! + private var permissionTimer: Timer? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + Log.info("launched, hotkey=\(prefs.hotkey.label), mode=\(prefs.triggerMode.rawValue)") + + controller.attach(hud: hud) + NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.controller.cancel() } + } + + status = StatusItemController() + status.onToggle = { [weak self] in self?.controller.toggle() } + status.onCancel = { [weak self] in self?.controller.cancel() } + status.onSettings = { [weak self] in self?.settings.show() } + + controller.onStateChange = { [weak self] state in + self?.status.update(state: state) + } + controller.onError = { [weak self] message in + self?.presentError(message) + } + + hotkey = HotkeyMonitor(spec: prefs.hotkey) + hotkey.handler = { [weak self] event in + self?.handle(event) + } + startHotkeyWithRetry() + + NotificationCenter.default.addObserver( + forName: .tinytssHotkeyChanged, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.controller.cancel() + self.hotkey.update(spec: self.prefs.hotkey) + self.startHotkeyWithRetry() + self.status.update(state: self.controller.state) + } + } + + NotificationCenter.default.addObserver(forName: .init("tinytss.recorder"), object: nil, queue: .main) { [weak self] notification in + MainActor.assumeIsolated { + self?.hotkey.suspended = notification.object as? Bool ?? false + self?.controller.cancel() + } + } + + Task { + _ = await Permissions.requestMicrophone() + await ModelManager.shared.refresh() + if ModelManager.shared.state == .needsDownload { + await ModelManager.shared.download() + } + SpeechEngine.warmUp() + } + + if !UserDefaults.standard.bool(forKey: "didShowOnboarding") || CommandLine.arguments.contains("--settings") { + UserDefaults.standard.set(true, forKey: "didShowOnboarding") + settings.show() + } + + if !Permissions.accessibility { + Permissions.promptAccessibility() + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + settings.show() + return true + } + + func applicationWillTerminate(_ notification: Notification) { + controller.cancel() + SpeechEngine.resetWarm() + hotkey?.stop() + permissionTimer?.invalidate() + } + + // MARK: - 热键 + + private func startHotkeyWithRetry() { + if hotkey.start() { + Log.info("hotkey tap running") + permissionTimer?.invalidate() + permissionTimer = nil + return + } + + Log.error("hotkey tap failed (no accessibility permission?)") + // 没有辅助功能权限时,定期重试(授权后无需重启即可生效) + guard permissionTimer == nil else { return } + permissionTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + if self.hotkey.start() { + self.permissionTimer?.invalidate() + self.permissionTimer = nil + } + } + } + } + + private func handle(_ event: HotkeyMonitor.Event) { + switch event { + case .pressed: + Log.info("hotkey pressed, state=\(controller.state)") + if prefs.triggerMode == .toggle { + controller.toggle() + } else { + controller.start() + } + case .released: + if prefs.triggerMode == .hold { + controller.stop() + } + case .cancel: + controller.cancel() + } + } + + private func presentError(_ message: String) { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "TinyTTS" + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: "知道了") + alert.runModal() + } +} diff --git a/Sources/TinyTTS/App/DictationController.swift b/Sources/TinyTTS/App/DictationController.swift new file mode 100644 index 0000000..d1661bc --- /dev/null +++ b/Sources/TinyTTS/App/DictationController.swift @@ -0,0 +1,211 @@ +import AppKit +import SwiftUI + +/// 听写状态机:空闲 → 录音 → 收尾 → 写入。 +@MainActor +final class DictationController: ObservableObject { + static let shared = DictationController() + + enum State: Equatable { + case idle + case starting + case recording + case finalizing + + var isBusy: Bool { self != .idle } + } + + @Published private(set) var state: State = .idle { + didSet { onStateChange?(state) } + } + + @Published private(set) var liveText: String = "" + @Published private(set) var lastResult: String = "" + + var onStateChange: ((State) -> Void)? + var onError: ((String) -> Void)? + + /// 设置页的「测试语音」:只识别,不写入任何 App + var testMode = false + + private let acquire: () async throws -> any RecognitionSession + private let microphone: () async -> Bool + private let secureInput: () -> Bool + private let replenish: @MainActor () -> Void + + init(acquire: @escaping () async throws -> any RecognitionSession = { try await SpeechEngine.acquire() }, + microphone: @escaping () async -> Bool = { await Permissions.requestMicrophone() }, + secureInput: @escaping () -> Bool = { Inserter.isSecureEventInputEnabled() }, + replenish: @escaping @MainActor () -> Void = { SpeechEngine.replenish() }) { + self.acquire = acquire + self.microphone = microphone + self.secureInput = secureInput + self.replenish = replenish + } + + private let prefs = Prefs.shared + private var session: (any RecognitionSession)? + private var startedAt: Date? + private var generation = UUID() + private var targetPID: pid_t? + private var silenceTask: Task? + private var lastVoice = Date() + private var hud: HUDController? + + func attach(hud: HUDController) { + self.hud = hud + } + + func toggle() { + switch state { + case .idle: start() + case .recording: stop() + case .starting: cancel() + default: break + } + } + + func start() { + guard state == .idle else { return } + guard !secureInput() else { return } + let token = UUID() + generation = token + targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier + state = .starting + liveText = "" + if !testMode, prefs.showHUD { + hud?.show() + } + + let t0 = Date() + Task { [self] in + do { + guard await microphone() else { + throw NSError(domain: "TinyTTS", code: 1, userInfo: [NSLocalizedDescriptionKey: "请在系统设置中授权麦克风。 "]) + } + let session = try await acquire() + Log.info("session acquired in \(Int(Date().timeIntervalSince(t0) * 1000))ms") + guard generation == token, state == .starting else { + await session.cancel() + return + } + self.session = session + + var firstText = true + session.onTextChanged = { [weak self] text in + if firstText, !text.isEmpty { + firstText = false + Log.info("first text after \(Int(Date().timeIntervalSince(t0) * 1000))ms") + } + guard self?.generation == token else { return } + self?.liveText = text + self?.hud?.update(text: text) + } + session.onAudioLevel = { [weak self] level in + guard let self, self.generation == token else { return } + self.hud?.update(level: level) + if level > 0.18 { self.lastVoice = Date() } + } + + session.onFailure = { [weak self] message in + guard let self, self.generation == token, self.state == .recording else { return } + self.cancel() + self.onError?("本地识别失败:" + message) + } + + try session.start() + startedAt = Date() + Log.info("recording started (total \(Int(Date().timeIntervalSince(t0) * 1000))ms)") + state = .recording + lastVoice = Date() + silenceTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard let self, self.generation == token, self.state == .recording else { return } + if self.prefs.silenceTimeout, Date().timeIntervalSince(self.lastVoice) >= 12 { + self.stop() + return + } + } + } + if prefs.playSound, !testMode { + NSSound(named: "Pop")?.play() + } + } catch { + guard generation == token else { return } + testMode = false + Log.error("start failed: \(error.localizedDescription)") + await session?.cancel() + session = nil + state = .idle + hud?.hide() + onError?(error.localizedDescription) + } + } + } + + func stop() { + if state == .starting { cancel(); return } + guard state == .recording, let session else { return } + silenceTask?.cancel() + let token = generation + state = .finalizing + hud?.setProcessing() + + let duration = Date().timeIntervalSince(startedAt ?? Date()) + let wasTest = testMode + testMode = false + + let t1 = Date() + Task { + let raw = await session.finish() + guard generation == token else { return } + Log.info("finalized in \(Int(Date().timeIntervalSince(t1) * 1000))ms, chars=\(raw.count)") + self.session = nil + self.hud?.hide() + replenish() + + var text = TextPostProcessor.finalize(raw, addPeriod: prefs.autoPeriod) + if duration < 0.35 || text.isEmpty { + text = "" + } + self.lastResult = text + + if wasTest { + self.state = .idle + return + } + + if text.isEmpty { + Log.info("empty result, skipped") + NSSound(named: "Basso")?.play() + self.state = .idle + return + } + + let outcome = Inserter.insert(text, method: prefs.insertMethod, expectedPID: targetPID) + Log.info("release to insertion dispatch \(Int(Date().timeIntervalSince(t1) * 1000))ms") + if prefs.playSound { NSSound(named: "Pop")?.play() } + Log.info("insert via \(outcome.method.rawValue) ok=\(outcome.ok), chars=\(text.count)") + if !outcome.ok { + self.onError?("未能确认文字写入,已复制到剪贴板;请先检查输入框,再决定是否按 ⌘V。") + } + self.state = .idle + } + } + + func cancel() { + guard state.isBusy else { return } + generation = UUID() + silenceTask?.cancel() + testMode = false + let current = session + session = nil + state = .idle + hud?.hide() + Task { + await current?.cancel() + replenish() + } + } +} diff --git a/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift b/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift new file mode 100644 index 0000000..f49eb09 --- /dev/null +++ b/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift @@ -0,0 +1,163 @@ +import AppKit +import CoreGraphics + +/// 全局热键监听(只监听,不吞事件,不影响原有快捷键)。 +/// 使用 CGEventTap,需要「辅助功能」或「输入监控」权限。 +final class HotkeyMonitor { + enum Event { + case pressed + case released + case cancel + } + + var handler: ((Event) -> Void)? + + private var tap: CFMachPort? + private var source: CFRunLoopSource? + private var spec: HotkeySpec + private var modifierDown = false + private var comboDown = false + var suspended = false + + init(spec: HotkeySpec) { + self.spec = spec + } + + deinit { + stop() + } + + static var hasPermission: Bool { + AXIsProcessTrusted() + } + + func update(spec: HotkeySpec) { + self.spec = spec + modifierDown = false + } + + @discardableResult + func start() -> Bool { + stop() + let mask: CGEventMask = + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.keyUp.rawValue) | + (1 << CGEventType.flagsChanged.rawValue) + + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: hotkeyCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + return false + } + + self.tap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + self.source = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return true + } + + func stop() { + if let tap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let source { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + if let tap { + CFMachPortInvalidate(tap) + } + tap = nil + source = nil + } + + fileprivate func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + modifierDown = false + comboDown = false + fire(.cancel) + if let tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + return + } + + guard !suspended else { return } + if Inserter.isSecureEventInputEnabled() { + if modifierDown || comboDown { fire(.cancel) } + modifierDown = false + comboDown = false + return + } + let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode)) + + // Esc 取消 + if type == .keyDown, keyCode == 53 { + fire(.cancel) + return + } + + switch spec.kind { + case .modifier: + guard type == .flagsChanged, keyCode == spec.keyCode else { return } + let down = CGEventSource.keyState(.combinedSessionState, key: CGKeyCode(spec.keyCode)) + if down, !modifierDown { + modifierDown = true + fire(.pressed) + } else if !down, modifierDown { + modifierDown = false + fire(.released) + } + + case .combo: + let required = CGEventFlags(rawValue: spec.modifiers) + if type == .flagsChanged, comboDown, !event.flags.contains(required) { + comboDown = false + fire(.released) + return + } + guard keyCode == spec.keyCode else { return } + + if type == .keyDown { + guard event.flags.contains(required) else { return } + let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 + guard !isRepeat, !comboDown else { return } + comboDown = true + fire(.pressed) + } else if type == .keyUp, comboDown { + comboDown = false + fire(.released) + } + } + } + + private func fire(_ event: Event) { + // tap 回调在 tap 所在线程(主线程)执行,仍需保证 UI 操作在主线程 + if Thread.isMainThread { + handler?(event) + } else { + DispatchQueue.main.async { [weak self] in + self?.handler?(event) + } + } + } +} + +private func hotkeyCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + if let refcon { + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + monitor.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) +} diff --git a/Sources/TinyTTS/Hotkey/HotkeySpec.swift b/Sources/TinyTTS/Hotkey/HotkeySpec.swift new file mode 100644 index 0000000..9e709cd --- /dev/null +++ b/Sources/TinyTTS/Hotkey/HotkeySpec.swift @@ -0,0 +1,86 @@ +import AppKit +import CoreGraphics + +/// 一个可持久化的全局热键描述。 +/// - modifier 类型:单独按某个修饰键(如右 Option) +/// - combo 类型:修饰键 + 普通键(如 ⌥Space) +struct HotkeySpec: Codable, Equatable { + enum Kind: String, Codable { + case modifier + case combo + } + + var kind: Kind + var keyCode: Int + /// combo 类型下必须同时按下的修饰键(CGEventFlags 原始值)。modifier 类型忽略。 + var modifiers: UInt64 + var label: String + + static let rightOption = HotkeySpec(kind: .modifier, keyCode: 61, modifiers: 0, label: "右 Option") + + /// 该热键对应的修饰键 flag(仅 modifier 类型有效) + var modifierFlag: CGEventFlags { + HotkeySpec.flag(forKeyCode: keyCode) + } + + static func flag(forKeyCode code: Int) -> CGEventFlags { + switch code { + case 54, 55: return .maskCommand + case 56, 60: return .maskShift + case 58, 61: return .maskAlternate + case 59, 62: return .maskControl + case 63: return .maskSecondaryFn + default: return [] + } + } + + static func modifierName(forKeyCode code: Int) -> String? { + switch code { + case 54: return "右 Command" + case 55: return "左 Command" + case 56: return "左 Shift" + case 60: return "右 Shift" + case 58: return "左 Option" + case 61: return "右 Option" + case 59: return "左 Control" + case 62: return "右 Control" + case 63: return "Fn" + default: return nil + } + } + + static func isModifierKeyCode(_ code: Int) -> Bool { + modifierName(forKeyCode: code) != nil + } + + static func keyName(forKeyCode code: Int) -> String { + let map: [Int: String] = [ + 49: "Space", 36: "Return", 48: "Tab", 53: "Esc", 51: "Delete", + 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 7: "X", + 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 14: "E", 15: "R", + 16: "Y", 17: "T", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6", + 23: "5", 24: "=", 25: "9", 26: "7", 27: "-", 28: "8", 29: "0", + 30: "]", 31: "O", 32: "U", 33: "[", 34: "I", 35: "P", 37: "L", + 38: "J", 39: "'", 40: "K", 41: ";", 42: "\\", 43: ",", 44: "/", + 45: "N", 46: "M", 47: ".", 50: "`" + ] + return map[code] ?? "键\(code)" + } + + static func flagsLabel(_ flags: UInt64) -> String { + var s = "" + if flags & CGEventFlags.maskControl.rawValue != 0 { s += "⌃" } + if flags & CGEventFlags.maskAlternate.rawValue != 0 { s += "⌥" } + if flags & CGEventFlags.maskShift.rawValue != 0 { s += "⇧" } + if flags & CGEventFlags.maskCommand.rawValue != 0 { s += "⌘" } + return s + } + + static func make(kind: Kind, keyCode: Int, modifiers: UInt64) -> HotkeySpec { + if kind == .modifier, let name = modifierName(forKeyCode: keyCode) { + return HotkeySpec(kind: .modifier, keyCode: keyCode, modifiers: 0, label: name) + } + let label = flagsLabel(modifiers) + keyName(forKeyCode: keyCode) + return HotkeySpec(kind: .combo, keyCode: keyCode, modifiers: modifiers, label: label) + } +} diff --git a/Sources/TinyTTS/SelfTest.swift b/Sources/TinyTTS/SelfTest.swift new file mode 100644 index 0000000..dfc4bd1 --- /dev/null +++ b/Sources/TinyTTS/SelfTest.swift @@ -0,0 +1,117 @@ +import AppKit +import AVFoundation +import Foundation +import Speech + +/// Feeds synthesized speech to a real installed local analyzer; does not test microphone or insertion. +enum SelfTest { + @MainActor + static func run() -> Never { + setvbuf(stdout, nil, _IONBF, 0) + NSApplication.shared.setActivationPolicy(.accessory) + Task.detached { + try? await Task.sleep(for: .seconds(90)) + print("SELFTEST FAILED: watchdog deadline (90s)") + exit(1) + } + Task { @MainActor in + do { + try await perform() + exit(0) + } catch { + print("SELFTEST FAILED: \(error.localizedDescription)") + exit(1) + } + } + NSApplication.shared.run() + exit(1) + } + + private static func failure(_ text: String) -> Error { + NSError(domain: "TinyTTS.SelfTest", code: 1, userInfo: [NSLocalizedDescriptionKey: text]) + } + + @MainActor + private static func perform() async throws { + let useDictation = CommandLine.arguments.contains("--dictation") + let locale = Locale(identifier: "zh-CN") + let expected = "今天的天气不错,我们下午三点开会。" + let module: any SpeechModule = useDictation + ? DictationTranscriber(locale: locale, preset: .progressiveShortDictation) + : SpeechTranscriber(locale: locale, preset: .progressiveTranscription) + let status = await AssetInventory.status(forModules: [module]) + print("Model: \(status)") + guard status == .installed else { throw failure("模型未安装;请先在设置中下载模型。") } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("tinystt-\(UUID()).aiff") + defer { try? FileManager.default.removeItem(at: url) } + print("Synthesizing audio…") + let say = Process() + say.executableURL = URL(fileURLWithPath: "/usr/bin/say") + say.arguments = ["-v", "Tingting", "-o", url.path, expected] + try say.run() + say.waitUntilExit() + guard say.terminationStatus == 0 else { throw failure("合成测试语音失败。") } + print("Reading audio…") + let file = try AVAudioFile(forReading: url) + guard let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [module]), + let converter = AVAudioConverter(from: file.processingFormat, to: format) else { + throw failure("无可用转换格式。") + } + print("Preparing analyzer…") + let analyzer = SpeechAnalyzer(modules: [module]) + try await analyzer.prepareToAnalyze(in: format) + print("Feeding audio…") + let pair = AsyncStream.makeStream() + let start = Date() + var segments: [Int: String] = [:] + var volatile = "" + var first: Date? + let results = Task { @MainActor in + func accept(_ range: CMTimeRange, _ text: String, _ final: Bool) { + if !text.isEmpty, first == nil { first = Date() } + if final { segments[Int(range.start.seconds * 1000)] = text; volatile = "" } + else { volatile = text } + } + if let transcriber = module as? SpeechTranscriber { + for try await result in transcriber.results { + accept(result.range, String(result.text.characters), result.isFinal) + } + } else if let transcriber = module as? DictationTranscriber { + for try await result in transcriber.results { + accept(result.range, String(result.text.characters), result.isFinal) + } + } + } + let analysis = Task { try await analyzer.start(inputSequence: pair.stream) } + while file.framePosition < file.length { + let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 1024)! + try file.read(into: buffer, frameCount: 1024) + if let converted = AudioMath.convert(buffer, using: converter, to: format) { + pair.continuation.yield(AnalyzerInput(buffer: converted)) + } + // Real-time feed: no accelerated latency claims. + try await Task.sleep(for: .seconds(Double(buffer.frameLength) / file.processingFormat.sampleRate)) + } + pair.continuation.finish() + let release = Date() + let finished: Bool? = await withTimeout(15) { + do { + try await analyzer.finalizeAndFinishThroughEndOfInput() + try await results.value + return true + } catch { print("Analyzer error: \(error)"); return false } + } + await analyzer.cancelAndFinishNow() + analysis.cancel() + results.cancel() + guard finished == true else { throw failure("识别超时或失败。") } + let text = segments.keys.sorted().compactMap { segments[$0] }.joined() + volatile + print("Recognized: \(text)") + print("First text from audio start: \(first.map { Int($0.timeIntervalSince(start) * 1000) } ?? -1)ms") + print("End of audio to final: \(Int(Date().timeIntervalSince(release) * 1000))ms") + guard !text.isEmpty, text.contains("天气"), text.contains("开会") else { + throw failure("识别结果未包含预期关键词。") + } + print("SELFTEST PASSED (synthesized audio only)") + } +} diff --git a/Sources/TinyTTS/Speech/ModelManager.swift b/Sources/TinyTTS/Speech/ModelManager.swift new file mode 100644 index 0000000..a4a1962 --- /dev/null +++ b/Sources/TinyTTS/Speech/ModelManager.swift @@ -0,0 +1,103 @@ +import Foundation +import Speech + +/// 负责检查并下载系统语音模型(首次使用必须完成,否则识别无声)。 +@MainActor +final class ModelManager: ObservableObject { + static let shared = ModelManager() + + enum State: Equatable { + case legacy + case unknown + case installed + case needsDownload + case downloading(Double) + case unsupported + case failed(String) + + var isReady: Bool { self == .installed } + } + + @Published private(set) var state: State = .unknown + private var progressTask: Task? + private var running = false + + func refresh() async { + if Prefs.shared.engineChoice == "legacy" { state = .legacy; return } + let locale = SpeechEngine.currentLocale + guard let selected = try? await SpeechBackend.module(locale: locale) else { state = .unsupported; return } + let status = await AssetInventory.status(forModules: [selected]) + Log.info("model status for \(locale.identifier): \(status)") + switch status { + case .installed: + state = .installed + case .supported: + state = .needsDownload + case .downloading: + state = .downloading(0) + case .unsupported: + state = .unsupported + @unknown default: + state = .unknown + } + } + + func ensureInstalled() async { + if state == .installed { return } + await refresh() + if state == .needsDownload { + await download() + } + } + + func download() async { + guard !running, Prefs.shared.engineChoice != "legacy" else { return } + running = true + defer { running = false } + + let locale = SpeechEngine.currentLocale + guard let module = try? await SpeechBackend.module(locale: locale) else { state = .unsupported; return } + + do { + // 预留名额,避免模型被系统回收 + _ = try? await AssetInventory.reserve(locale: locale) + + guard let request = try await AssetInventory.assetInstallationRequest(supporting: [module]) else { + state = .installed + return + } + + progressTask?.cancel() + progressTask = Task { @MainActor in + while !Task.isCancelled { + self.state = .downloading(request.progress.fractionCompleted) + try? await Task.sleep(nanoseconds: 200_000_000) + } + } + + try await request.downloadAndInstall() + progressTask?.cancel() + progressTask = nil + state = .installed + SpeechEngine.resetWarm() + SpeechEngine.warmUp() + } catch { + progressTask?.cancel() + progressTask = nil + state = .failed(error.localizedDescription) + } + } + + /// 语音识别是否可用(不阻塞 UI 的快速判定) + var statusText: String { + switch state { + case .legacy: "旧版本地引擎(模型由系统听写管理)" + case .unknown: "检查中…" + case .installed: "已就绪" + case .needsDownload: "需要下载模型" + case .downloading(let p): "下载中 \(Int(p * 100))%" + case .unsupported: "当前系统不支持该语言" + case .failed(let msg): "失败:\(msg)" + } + } +} diff --git a/Sources/TinyTTS/Speech/SpeechBackend.swift b/Sources/TinyTTS/Speech/SpeechBackend.swift new file mode 100644 index 0000000..427e81b --- /dev/null +++ b/Sources/TinyTTS/Speech/SpeechBackend.swift @@ -0,0 +1,105 @@ +import AVFoundation +import Speech + +@MainActor +protocol RecognitionSession: AnyObject { + var onTextChanged: ((String) -> Void)? { get set } + var onAudioLevel: ((Float) -> Void)? { get set } + var onFailure: ((String) -> Void)? { get set } + func start() throws + func finish() async -> String + func cancel() async +} + +@MainActor +enum SpeechBackend { + static func module(locale: Locale) async throws -> any SpeechModule { + let choice = Prefs.shared.engineChoice + if choice != "dictation", choice != "legacy", + let supported = await SpeechTranscriber.supportedLocale(equivalentTo: locale) { + return SpeechTranscriber(locale: supported, preset: .progressiveTranscription) + } + if choice == "auto" || choice == "dictation", + let supported = await DictationTranscriber.supportedLocale(equivalentTo: locale) { + return DictationTranscriber(locale: supported, preset: .progressiveShortDictation) + } + throw NSError(domain: "TinyTTS", code: 2, userInfo: [NSLocalizedDescriptionKey: "当前引擎不支持该语言,请选择自动或旧版本地识别。"]) + } +} + +@MainActor +final class LegacySession: RecognitionSession { + var onTextChanged: ((String) -> Void)? + var onAudioLevel: ((Float) -> Void)? + var onFailure: ((String) -> Void)? + private let recognizer: SFSpeechRecognizer + private let request = SFSpeechAudioBufferRecognitionRequest() + private let engine = AVAudioEngine() + private var recognition: SFSpeechRecognitionTask? + private var text = "" + private var done = false + private var tapped = false + + private init(_ recognizer: SFSpeechRecognizer) { + self.recognizer = recognizer + request.requiresOnDeviceRecognition = true + request.shouldReportPartialResults = true + request.addsPunctuation = true + } + + static func make(locale: Locale) async throws -> LegacySession { + // Do not prompt during background warm-up; request only on explicit recording. + guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.supportsOnDeviceRecognition else { + throw NSError(domain: "TinyTTS", code: 3, userInfo: [NSLocalizedDescriptionKey: "该语言没有可用的本地语音引擎。"]) + } + return LegacySession(recognizer) + } + + func start() throws { + guard SFSpeechRecognizer.authorizationStatus() == .authorized else { + SFSpeechRecognizer.requestAuthorization { _ in } + throw NSError(domain: "TinyTTS", code: 4, userInfo: [NSLocalizedDescriptionKey: "旧版引擎需要语音识别权限;请授权后再次开始。"]) + } + guard recognizer.isAvailable else { + throw NSError(domain: "TinyTTS", code: 5, userInfo: [NSLocalizedDescriptionKey: "本地识别当前不可用,请检查系统听写模型。"]) + } + recognition = recognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let result { + self.text = result.bestTranscription.formattedString + self.onTextChanged?(self.text) + if result.isFinal { self.done = true } + } + if let error, !self.done { self.done = true; self.onFailure?(error.localizedDescription) } + } + } + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.sampleRate > 0 else { throw NSError(domain: "TinyTTS", code: 6, userInfo: [NSLocalizedDescriptionKey: "没有可用麦克风。 "]) } + let request = self.request + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + request.append(buffer) + let level = AudioMath.level(of: buffer) + Task { @MainActor in self?.onAudioLevel?(level) } + } + tapped = true + do { try engine.start() } catch { stopAudio(); recognition?.cancel(); throw error } + } + + private func stopAudio() { + engine.stop() + if tapped { engine.inputNode.removeTap(onBus: 0); tapped = false } + } + + func finish() async -> String { + stopAudio() + request.endAudio() + let deadline = Date().addingTimeInterval(2) + while !done, Date() < deadline { try? await Task.sleep(for: .milliseconds(20)) } + recognition?.cancel() + return text + } + + func cancel() async { stopAudio(); request.endAudio(); recognition?.cancel(); done = true } +} diff --git a/Sources/TinyTTS/Speech/SpeechEngine.swift b/Sources/TinyTTS/Speech/SpeechEngine.swift new file mode 100644 index 0000000..cc2ba56 --- /dev/null +++ b/Sources/TinyTTS/Speech/SpeechEngine.swift @@ -0,0 +1,62 @@ +import Foundation +import Speech + +@MainActor +enum SpeechEngine { + private static var warm: (any RecognitionSession)? + private static var warming: Task? + private static var expiry: Task? + private static var generation = UUID() + + static var currentLocale: Locale { Locale(identifier: Prefs.shared.languageCode) } + + static func warmUp() { + guard warming == nil, warm == nil else { return } + let token = generation + let locale = currentLocale + warming = Task { + let session = try? await make(locale: locale) + guard token == generation else { await session?.cancel(); return } + warming = nil + warm = session + expiry?.cancel() + expiry = Task { + try? await Task.sleep(for: .seconds(600)) + guard !Task.isCancelled, token == generation else { return } + let old = warm + warm = nil + await old?.cancel() + } + } + } + + static func acquire() async throws -> any RecognitionSession { + if let warming { await warming.value } + expiry?.cancel() + if let session = warm { warm = nil; return session } + return try await make(locale: currentLocale) + } + + private static func make(locale: Locale) async throws -> any RecognitionSession { + if Prefs.shared.engineChoice == "legacy" { return try await LegacySession.make(locale: locale) } + do { return try await SpeechSession.make(locale: locale) } + catch { + guard Prefs.shared.engineChoice == "auto" else { throw error } + Log.error("primary analyzer unavailable; trying local dictation") + if let session = try? await SpeechSession.make(locale: locale, forceDictation: true) { return session } + return try await LegacySession.make(locale: locale) + } + } + + static func replenish() { warmUp() } + + static func resetWarm() { + generation = UUID() + warming?.cancel() + warming = nil + expiry?.cancel() + let old = warm + warm = nil + Task { await old?.cancel() } + } +} diff --git a/Sources/TinyTTS/Speech/SpeechSession.swift b/Sources/TinyTTS/Speech/SpeechSession.swift new file mode 100644 index 0000000..20d0a2e --- /dev/null +++ b/Sources/TinyTTS/Speech/SpeechSession.swift @@ -0,0 +1,270 @@ +import AVFoundation +import Foundation +import Speech + +/// 一次听写会话:音频采集 → 本地 SpeechAnalyzer → 文本流。 +/// 所有可变状态都在主线程访问。 +@MainActor +final class SpeechSession: RecognitionSession { + private let analyzer: SpeechAnalyzer + private let transcriber: (any SpeechModule) + private let analyzerFormat: AVAudioFormat + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var analyzerTask: Task? + private var resultsTask: Task? + private var engine: AVAudioEngine? + private var tapInstalled = false + private var isFinished = false + + private var segments: [Int: String] = [:] + private var volatileKey: Int? + private var volatileText = "" + + var onTextChanged: ((String) -> Void)? + var onAudioLevel: ((Float) -> Void)? + var onFailure: ((String) -> Void)? + + private init(analyzer: SpeechAnalyzer, + transcriber: any SpeechModule, + format: AVAudioFormat, + stream: AsyncStream, + continuation: AsyncStream.Continuation) { + self.analyzer = analyzer + self.transcriber = transcriber + self.analyzerFormat = format + self.stream = stream + self.continuation = continuation + } + + static func make(locale: Locale, forceDictation: Bool = false) async throws -> SpeechSession { + let transcriber: any SpeechModule + if forceDictation { + transcriber = DictationTranscriber(locale: locale, preset: .progressiveShortDictation) + } else { + transcriber = try await SpeechBackend.module(locale: locale) + } + + let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) + ?? AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)! + + let analyzer = SpeechAnalyzer(modules: [transcriber]) + try await analyzer.prepareToAnalyze(in: format) + + var captured: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .unbounded) { cont in + captured = cont + } + + return SpeechSession(analyzer: analyzer, + transcriber: transcriber, + format: format, + stream: stream, + continuation: captured) + } + + var currentText: String { + let ordered = segments.keys.sorted().compactMap { segments[$0] } + return ordered.joined() + volatileText + } + + func start() throws { + guard !isFinished else { return } + try startAudio() + + resultsTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + if let module = self.transcriber as? SpeechTranscriber { + for try await result in module.results { + self.apply(key: Int((result.range.start.seconds * 1000).rounded()), text: String(result.text.characters), final: result.isFinal) + } + } else if let module = self.transcriber as? DictationTranscriber { + for try await result in module.results { + self.apply(key: Int((result.range.start.seconds * 1000).rounded()), text: String(result.text.characters), final: result.isFinal) + } + } + } catch { + if !self.isFinished { self.onFailure?(error.localizedDescription) } + } + } + + let analyzerTask = self.analyzer + let stream = self.stream + self.analyzerTask = Task { + do { + try await analyzerTask.start(inputSequence: stream) + } catch { + if !self.isFinished { self.onFailure?(error.localizedDescription) } + } + } + } + + func finish() async -> String { + guard !isFinished else { return currentText } + isFinished = true + stopAudio() + continuation.finish() + + let analyzer = self.analyzer + _ = await withTimeout(2.0) { + try? await analyzer.finalizeAndFinishThroughEndOfInput() + } + if let resultsTask { + _ = await withTimeout(1.0) { await resultsTask.value } + } + await analyzer.cancelAndFinishNow() + analyzerTask?.cancel() + resultsTask?.cancel() + return currentText + } + + func cancel() async { + isFinished = true + stopAudio() + continuation.finish() + analyzerTask?.cancel() + resultsTask?.cancel() + await analyzer.cancelAndFinishNow() + } + + // MARK: - 音频 + + private func startAudio() throws { + let engine = AVAudioEngine() + self.engine = engine + let input = engine.inputNode + let hwFormat = input.inputFormat(forBus: 0) + guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { + throw NSError(domain: "TinyTTS", code: -10, + userInfo: [NSLocalizedDescriptionKey: "没有可用的麦克风输入"]) + } + + guard let converter = AVAudioConverter(from: hwFormat, to: analyzerFormat) else { + throw NSError(domain: "TinyTTS", code: -11, + userInfo: [NSLocalizedDescriptionKey: "无法创建音频格式转换器"]) + } + + let continuation = self.continuation + let format = self.analyzerFormat + var frameCounter = 0 + + input.installTap(onBus: 0, bufferSize: 1024, format: hwFormat) { [weak self] buffer, _ in + frameCounter += 1 + if frameCounter % 2 == 0 { + let level = AudioMath.level(of: buffer) + Task { @MainActor in self?.onAudioLevel?(level) } + } + guard let converted = AudioMath.convert(buffer, using: converter, to: format) else { return } + continuation.yield(AnalyzerInput(buffer: converted)) + } + tapInstalled = true + + engine.prepare() + try engine.start() + } + + private func stopAudio() { + guard let engine else { return } + if engine.isRunning { + engine.stop() + } + if tapInstalled { + engine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + self.engine = nil + } + + // MARK: - 结果 + + private func apply(key: Int, text: String, final: Bool) { + if final { + if text.isEmpty { + segments.removeValue(forKey: key) + } else { + segments[key] = text + } + if volatileKey == key { + volatileKey = nil + volatileText = "" + } + } else { + volatileKey = key + volatileText = text + } + onTextChanged?(currentText) + } +} + +// MARK: - 工具 + +enum AudioMath { + static func level(of buffer: AVAudioPCMBuffer) -> Float { + guard let data = buffer.floatChannelData else { return 0 } + let count = Int(buffer.frameLength) + guard count > 0 else { return 0 } + let channel = data[0] + var sum: Float = 0 + var i = 0 + while i < count { + let v = channel[i] + sum += v * v + i += 4 + } + let rms = sqrtf(sum / Float((count + 3) / 4)) + // 映射到 0...1,口语音量大致在 -50dB ~ -10dB + let db = 20 * log10f(max(rms, 0.00001)) + return min(1, max(0, (db + 55) / 45)) + } + + static func convert(_ buffer: AVAudioPCMBuffer, + using converter: AVAudioConverter, + to format: AVAudioFormat) -> AVAudioPCMBuffer? { + let ratio = format.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024 + guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return nil } + + var consumed = false + var error: NSError? + let status = converter.convert(to: out, error: &error) { _, outStatus in + if consumed { + outStatus.pointee = .noDataNow + return nil + } + consumed = true + outStatus.pointee = .haveData + return buffer + } + if status == .error { return nil } + return out.frameLength > 0 ? out : nil + } +} + +/// One-shot gate allows a deadline to return even when a framework operation ignores cancellation. +private final class TimeoutGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + init(_ continuation: CheckedContinuation) { self.continuation = continuation } + func resolve(_ value: T?) { + lock.lock() + let current = continuation + continuation = nil + lock.unlock() + current?.resume(returning: value) + } +} + +@discardableResult +func withTimeout(_ seconds: Double, _ operation: @escaping () async -> T) async -> T? { + await withCheckedContinuation { continuation in + let gate = TimeoutGate(continuation) + let work = Task { gate.resolve(await operation()) } + Task { + try? await Task.sleep(for: .seconds(seconds)) + gate.resolve(nil) + work.cancel() + } + } +} diff --git a/Sources/TinyTTS/Support/LaunchAtLogin.swift b/Sources/TinyTTS/Support/LaunchAtLogin.swift new file mode 100644 index 0000000..f5f5553 --- /dev/null +++ b/Sources/TinyTTS/Support/LaunchAtLogin.swift @@ -0,0 +1,34 @@ +import Foundation +import ServiceManagement + +enum LaunchAtLogin { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static var statusText: String { + switch SMAppService.mainApp.status { + case .enabled: "已开启" + case .notRegistered: "未开启" + case .requiresApproval: "需要在系统设置中批准" + case .notFound: "未安装到「应用程序」文件夹" + @unknown default: "未知状态" + } + } + + @discardableResult + static func set(_ enabled: Bool) -> String? { + do { + if enabled { + if SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } + } else { + try SMAppService.mainApp.unregister() + } + return nil + } catch { + return error.localizedDescription + } + } +} diff --git a/Sources/TinyTTS/Support/Log.swift b/Sources/TinyTTS/Support/Log.swift new file mode 100644 index 0000000..48f04ec --- /dev/null +++ b/Sources/TinyTTS/Support/Log.swift @@ -0,0 +1,49 @@ +import Foundation + +/// 轻量日志:写到 ~/Library/Logs/TinyTTS.log,方便排查(也便于无 UI 时验证)。 +enum Log { + private static let queue = DispatchQueue(label: "tinytss.log") + private static let url: URL = { + let dir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("TinyTTS.log") + }() + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MM-dd HH:mm:ss.SSS" + return f + }() + + static func info(_ message: String) { + write("INFO", message) + } + + static func error(_ message: String) { + write("ERR ", message) + } + + private static func write(_ level: String, _ message: String) { + queue.async { + let line = "[\(formatter.string(from: Date()))] \(level) \(message)\n" + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int, size > 1_000_000 { + let previous = url.appendingPathExtension("1") + try? FileManager.default.removeItem(at: previous) + try? FileManager.default.moveItem(at: url, to: previous) + } + guard let data = line.data(using: .utf8) else { return } + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: url) + } + #if DEBUG + FileHandle.standardError.write(line.data(using: .utf8) ?? Data()) + #endif + } + } +} diff --git a/Sources/TinyTTS/Support/Permissions.swift b/Sources/TinyTTS/Support/Permissions.swift new file mode 100644 index 0000000..299ae3e --- /dev/null +++ b/Sources/TinyTTS/Support/Permissions.swift @@ -0,0 +1,42 @@ +import AppKit +import AVFoundation + +enum Permissions { + enum MicStatus { + case granted, denied, undetermined + } + + static var microphone: MicStatus { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: return .granted + case .denied, .restricted: return .denied + case .notDetermined: return .undetermined + @unknown default: return .undetermined + } + } + + static var accessibility: Bool { + AXIsProcessTrusted() + } + + static func requestMicrophone() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return true + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .audio) + default: + return false + } + } + + static func promptAccessibility() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + AXIsProcessTrustedWithOptions(options) + } + + static func openPrivacyPane(_ pane: String) { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")! + NSWorkspace.shared.open(url) + } +} diff --git a/Sources/TinyTTS/Support/Preferences.swift b/Sources/TinyTTS/Support/Preferences.swift new file mode 100644 index 0000000..bc7531e --- /dev/null +++ b/Sources/TinyTTS/Support/Preferences.swift @@ -0,0 +1,120 @@ +import Foundation +import SwiftUI + +enum TriggerMode: String, CaseIterable, Identifiable { + case toggle + case hold + + var id: String { rawValue } + var title: String { + switch self { + case .toggle: "按一次开始,再按一次结束" + case .hold: "按住说话,松开结束" + } + } +} + +enum InsertMethod: String, CaseIterable, Identifiable { + case auto + case axOnly + case paste + + var id: String { rawValue } + var title: String { + switch self { + case .auto: "自动(AX 失败则粘贴)" + case .axOnly: "仅 AX 直接写入" + case .paste: "始终使用粘贴" + } + } +} + +@MainActor +final class Prefs: ObservableObject { + static let shared = Prefs() + + private let defaults = UserDefaults.standard + + @Published var hotkey: HotkeySpec { + didSet { + if let data = try? JSONEncoder().encode(hotkey) { + defaults.set(data, forKey: Keys.hotkey) + } + NotificationCenter.default.post(name: .tinytssHotkeyChanged, object: nil) + } + } + + @Published var triggerMode: TriggerMode { + didSet { defaults.set(triggerMode.rawValue, forKey: Keys.triggerMode) } + } + + @Published var insertMethod: InsertMethod { + didSet { defaults.set(insertMethod.rawValue, forKey: Keys.insertMethod) } + } + + @Published var autoPeriod: Bool { + didSet { defaults.set(autoPeriod, forKey: Keys.autoPeriod) } + } + + @Published var showHUD: Bool { + didSet { defaults.set(showHUD, forKey: Keys.showHUD) } + } + + @Published var playSound: Bool { + didSet { defaults.set(playSound, forKey: Keys.playSound) } + } + + @Published var languageCode: String { + didSet { defaults.set(languageCode, forKey: Keys.languageCode) } + } + + @Published var silenceTimeout: Bool { + didSet { defaults.set(silenceTimeout, forKey: "silenceTimeout") } + } + + @Published var compactIcon: Bool { + didSet { defaults.set(compactIcon, forKey: "compactIcon") } + } + + @Published var hudAtBottom: Bool { + didSet { defaults.set(hudAtBottom, forKey: "hudAtBottom") } + } + + @Published var engineChoice: String { + didSet { defaults.set(engineChoice, forKey: "engineChoice") } + } + + private enum Keys { + static let hotkey = "hotkey" + static let triggerMode = "triggerMode" + static let insertMethod = "insertMethod" + static let autoPeriod = "autoPeriod" + static let showHUD = "showHUD" + static let playSound = "playSound" + static let languageCode = "languageCode" + } + + private init() { + if let data = defaults.data(forKey: Keys.hotkey), + let spec = try? JSONDecoder().decode(HotkeySpec.self, from: data) { + hotkey = spec + } else { + hotkey = .rightOption + } + triggerMode = TriggerMode(rawValue: defaults.string(forKey: Keys.triggerMode) ?? "") ?? .toggle + insertMethod = InsertMethod(rawValue: defaults.string(forKey: Keys.insertMethod) ?? "") ?? .auto + autoPeriod = defaults.object(forKey: Keys.autoPeriod) as? Bool ?? true + showHUD = defaults.object(forKey: Keys.showHUD) as? Bool ?? true + playSound = defaults.object(forKey: Keys.playSound) as? Bool ?? true + engineChoice = defaults.string(forKey: "engineChoice") ?? "auto" + silenceTimeout = defaults.object(forKey: "silenceTimeout") as? Bool ?? true + compactIcon = defaults.object(forKey: "compactIcon") as? Bool ?? true + hudAtBottom = defaults.bool(forKey: "hudAtBottom") + languageCode = "zh-CN" + } +} + +extension Notification.Name { + static let tinytssHotkeyChanged = Notification.Name("tinytss.hotkeyChanged") + static let tinytssOpenSettings = Notification.Name("tinytss.openSettings") +} diff --git a/Sources/TinyTTS/Text/TextInserter.swift b/Sources/TinyTTS/Text/TextInserter.swift new file mode 100644 index 0000000..d692ff6 --- /dev/null +++ b/Sources/TinyTTS/Text/TextInserter.swift @@ -0,0 +1,180 @@ +import AppKit +import CoreGraphics +import Foundation + +/// 把文字写进当前焦点输入框。 +/// 策略:AX 直接写入(可验证)→ 剪贴板 + ⌘V → 失败则留在剪贴板。 +enum Inserter { + enum Method: String { + case accessibility = "AX" + case paste = "⌘V" + case clipboard = "剪贴板" + } + + struct Outcome { + var method: Method + var ok: Bool + } + + static func insert(_ text: String, method: InsertMethod, expectedPID: pid_t? = nil) -> Outcome { + guard !text.isEmpty else { return Outcome(method: .accessibility, ok: false) } + + if isSecureEventInputEnabled() || (expectedPID != nil && NSWorkspace.shared.frontmostApplication?.processIdentifier != expectedPID) { + // 密码框禁止注入,直接放剪贴板更安全 + copyToPasteboard(text) + return Outcome(method: .clipboard, ok: false) + } + + switch method { + case .axOnly: + let result = insertViaAX(text) + if result != .inserted { copyToPasteboard(text) } + return Outcome(method: .accessibility, ok: result == .inserted) + case .paste: + paste(text) + return Outcome(method: .paste, ok: true) + case .auto: + switch insertViaAX(text) { + case .inserted: return Outcome(method: .accessibility, ok: true) + case .uncertain: + copyToPasteboard(text) + return Outcome(method: .clipboard, ok: false) + case .unavailable: + paste(text) + return Outcome(method: .paste, ok: true) + } + } + } + + private enum AXResult { case inserted, unavailable, uncertain } + + // MARK: - AX + + static func focusedElement() -> AXUIElement? { + let system = AXUIElementCreateSystemWide() + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + return (value as! AXUIElement) + } + + private static func insertViaAX(_ text: String) -> AXResult { + guard AXIsProcessTrusted(), let element = focusedElement() else { return .unavailable } + + let beforeValue = stringAttribute(element, kAXValueAttribute) + var rangeRef: CFTypeRef? + guard let beforeValue, + AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success, + let rangeRef, CFGetTypeID(rangeRef) == AXValueGetTypeID() else { return .unavailable } + var range = CFRange() + guard AXValueGetValue(rangeRef as! AXValue, .cfRange, &range), + range.location >= 0, range.length >= 0, + range.location + range.length <= (beforeValue as NSString).length else { return .unavailable } + let expected = (beforeValue as NSString).replacingCharacters( + in: NSRange(location: range.location, length: range.length), with: text) + let result = AXUIElementSetAttributeValue(element, kAXSelectedTextAttribute as CFString, text as CFString) + guard result == .success else { return .unavailable } + // A successful AX write must never fall through to paste: that could insert twice. + guard stringAttribute(element, kAXValueAttribute) == expected else { + Log.error("AX accepted write but readback differs; avoiding duplicate paste") + return .uncertain + } + return .inserted + } + + private static func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value else { return nil } + if let str = value as? String { return str } + if let str = value as? NSString { return str as String } + return nil + } + + private static func intAttribute(_ element: AXUIElement, _ attribute: String) -> Int? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == CFNumberGetTypeID() else { return nil } + return (value as? NSNumber)?.intValue + } + + /// 焦点输入框在屏幕上的位置(AX 坐标系,左上原点),用于 HUD 定位。 + static func focusedFrame() -> CGRect? { + guard let element = focusedElement() else { return nil } + var posRef: CFTypeRef? + var sizeRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &posRef) == .success, + AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success, + let posRef, let sizeRef, + CFGetTypeID(posRef) == AXValueGetTypeID(), + CFGetTypeID(sizeRef) == AXValueGetTypeID() else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + AXValueGetValue((posRef as! AXValue), .cgPoint, &point) + AXValueGetValue((sizeRef as! AXValue), .cgSize, &size) + guard size.width > 1, size.height > 1 else { return nil } + return CGRect(origin: point, size: size) + } + + // MARK: - 剪贴板 + + private static func copyToPasteboard(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + } + + private static func paste(_ text: String) { + let pb = NSPasteboard.general + let targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier + let saved: [[NSPasteboard.PasteboardType: Data]] = (pb.pasteboardItems ?? []).map { item in + Dictionary(uniqueKeysWithValues: item.types.compactMap { type in item.data(forType: type).map { (type, $0) } }) + } + + pb.clearContents() + pb.setString(text, forType: .string) + + let writtenChange = pb.changeCount + + // 让剪贴板写入先落地,再发按键 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard !isSecureEventInputEnabled(), NSWorkspace.shared.frontmostApplication?.processIdentifier == targetPID else { return } + let source = CGEventSource(stateID: .combinedSessionState) + let vKey: CGKeyCode = 9 + let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true) + down?.flags = .maskCommand + down?.post(tap: .cghidEventTap) + let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) + up?.flags = .maskCommand + up?.post(tap: .cghidEventTap) + + // 恢复用户原本的剪贴板(若没有被改掉) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if pb.changeCount == writtenChange { + pb.clearContents() + let items = saved.map { contents in + let item = NSPasteboardItem() + for (type, data) in contents { item.setData(data, forType: type) } + return item + } + pb.writeObjects(items) + } + } + } + } + + // MARK: - 安全输入 + + static func isSecureEventInputEnabled() -> Bool { + guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "IsSecureEventInputEnabled") else { + return false + } + typealias Fn = @convention(c) () -> UInt8 + let fn = unsafeBitCast(sym, to: Fn.self) + return fn() != 0 + } +} diff --git a/Sources/TinyTTS/Text/TextPostProcessor.swift b/Sources/TinyTTS/Text/TextPostProcessor.swift new file mode 100644 index 0000000..636a3db --- /dev/null +++ b/Sources/TinyTTS/Text/TextPostProcessor.swift @@ -0,0 +1,28 @@ +import Foundation + +/// 文本后处理:保留系统标点,不清洗,只在缺少句末标点时补句号。 +enum TextPostProcessor { + private static let terminalPunctuation: Set = [ + "。", "!", "?", "…", ";", ":", ".", "!", "?", ";", ":", "\n" + ] + + private static let closingMarks: Set = ["”", "\"", "」", "』", ")", ")", "》", "】", "’", "'"] + + static func finalize(_ raw: String, addPeriod: Bool) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "" } + + guard addPeriod else { return text } + + var probe = text + while let last = probe.last, closingMarks.contains(last) { + probe.removeLast() + } + guard let last = probe.last else { return text } + if !terminalPunctuation.contains(last) { + text += "。" + } + return text + } + +} diff --git a/Sources/TinyTTS/UI/HUDController.swift b/Sources/TinyTTS/UI/HUDController.swift new file mode 100644 index 0000000..2c84b94 --- /dev/null +++ b/Sources/TinyTTS/UI/HUDController.swift @@ -0,0 +1,204 @@ +import AppKit +import SwiftUI + +@MainActor +final class HUDModel: ObservableObject { + enum Phase { + case listening + case processing + } + + @Published var text: String = "" + @Published var level: Float = 0 + @Published var phase: Phase = .listening + @Published var hint: String = "再按一次结束 · Esc 取消" +} + +struct HUDView: View { + @ObservedObject var model: HUDModel + + var body: some View { + HStack(alignment: .center, spacing: 14) { + Waveform(level: model.level, active: model.phase == .listening) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + if model.phase == .processing { + ProgressView() + .controlSize(.small) + .scaleEffect(0.7) + } + Text(displayText) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(model.text.isEmpty ? Color.secondary : Color.primary) + .lineLimit(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + Text(model.phase == .processing ? "正在整理文字…" : model.hint) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(width: 480, height: 104, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 0.5) + ) + } + + private var displayText: String { + if model.text.isEmpty { + return model.phase == .processing ? " " : "聆听中…" + } + return model.text + } +} + +private struct Waveform: View { + var level: Float + var active: Bool + + var body: some View { + HStack(alignment: .center, spacing: 3) { + ForEach(0..<5, id: \.self) { index in + Capsule() + .fill(active ? Color.accentColor : Color.secondary.opacity(0.4)) + .frame(width: 3, height: height(for: index)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .animation(.easeOut(duration: 0.12), value: level) + } + + private func height(for index: Int) -> CGFloat { + let weights: [CGFloat] = [0.45, 0.75, 1.0, 0.7, 0.5] + let base: CGFloat = 5 + let maxHeight: CGFloat = 30 + let l = CGFloat(max(0, min(1, level))) + return base + (maxHeight - base) * l * weights[index] + } +} + +@MainActor +final class HUDController { + private var panel: NSPanel? + private var tracking: Timer? + private let model = HUDModel() + + var isVisible: Bool { panel?.isVisible ?? false } + + func show() { + model.text = "" + model.level = 0 + model.phase = .listening + model.hint = "\(Prefs.shared.languageCode) · " + (Prefs.shared.triggerMode == .hold ? "松开结束 · Esc 取消" : "再按一次结束 · Esc 取消") + + let panel = ensurePanel() + position(panel) + panel.alphaValue = 0 + panel.orderFrontRegardless() + tracking?.invalidate() + tracking = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let panel = self.panel else { return } + self.position(panel) + } + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + panel.animator().alphaValue = 1 + } + } + + func update(text: String) { + model.text = text + } + + func update(level: Float) { + // 平滑一下,避免抖动 + model.level = model.level * 0.6 + level * 0.4 + } + + func setProcessing() { + model.phase = .processing + } + + func hide() { + tracking?.invalidate() + tracking = nil + guard let panel, panel.isVisible else { return } + panel.orderOut(nil) + } + + private func ensurePanel() -> NSPanel { + if let panel { return panel } + + let panel = CaptionPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 104), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .statusBar + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.ignoresMouseEvents = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + panel.contentView = NSHostingView(rootView: HUDView(model: model)) + self.panel = panel + return panel + } + + private func position(_ panel: NSPanel) { + let size = panel.frame.size + let screen = screenUnderMouse() ?? NSScreen.main + guard let screen else { return } + + var origin: CGPoint + + if !Prefs.shared.hudAtBottom, let ax = Inserter.focusedFrame() { + // AX 坐标系:主屏左上角为原点,y 向下 + let primaryHeight = NSScreen.screens + .first { $0.frame.origin == .zero }?.frame.height ?? screen.frame.height + let appKitY = primaryHeight - ax.maxY + var x = ax.minX + ax.width / 2 - size.width / 2 + var y = appKitY - size.height - 10 + + // 下方空间不够时显示到输入框上方 + if y < screen.visibleFrame.minY + 8 { + y = primaryHeight - ax.minY + 10 + } + x = min(max(x, screen.visibleFrame.minX + 8), screen.visibleFrame.maxX - size.width - 8) + y = min(max(y, screen.visibleFrame.minY + 8), screen.visibleFrame.maxY - size.height - 8) + origin = CGPoint(x: x, y: y) + } else { + origin = CGPoint(x: screen.frame.midX - size.width / 2, + y: screen.visibleFrame.minY + 120) + } + + panel.setFrame(NSRect(origin: origin, size: size), display: true) + } + + private func screenUnderMouse() -> NSScreen? { + let location = NSEvent.mouseLocation + return NSScreen.screens.first { $0.frame.contains(location) } + } +} + +private final class CaptionPanel: NSPanel { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} diff --git a/Sources/TinyTTS/UI/HotkeyRecorder.swift b/Sources/TinyTTS/UI/HotkeyRecorder.swift new file mode 100644 index 0000000..2e49a44 --- /dev/null +++ b/Sources/TinyTTS/UI/HotkeyRecorder.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI + +@MainActor +final class HotkeyRecorderModel: ObservableObject { + @Published private(set) var isRecording = false + + var onCapture: ((HotkeySpec) -> Void)? + private var monitor: Any? + private var pendingModifier: Int? + + func begin() { + guard !isRecording else { return } + isRecording = true + NotificationCenter.default.post(name: .init("tinytss.recorder"), object: true) + pendingModifier = nil + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in + guard let self else { return event } + self.handle(event) + return nil + } + } + + func end() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + pendingModifier = nil + isRecording = false + NotificationCenter.default.post(name: .init("tinytss.recorder"), object: false) + } + + private func handle(_ event: NSEvent) { + if event.type == .keyDown { + let code = Int(event.keyCode) + if code == 53 { + end() + return + } + let flags = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard !flags.isEmpty else { + NSSound.beep() + return + } + onCapture?(HotkeySpec.make(kind: .combo, keyCode: code, modifiers: flags.cgEventRawValue)) + end() + return + } + + let code = Int(event.keyCode) + guard HotkeySpec.isModifierKeyCode(code) else { return } + let flag = HotkeySpec.nsFlag(forKeyCode: code) + if event.modifierFlags.contains(flag) { + pendingModifier = code + } else if pendingModifier == code { + onCapture?(HotkeySpec.make(kind: .modifier, keyCode: code, modifiers: 0)) + end() + } + } +} + +extension HotkeySpec { + static func nsFlag(forKeyCode code: Int) -> NSEvent.ModifierFlags { + switch code { + case 54, 55: return .command + case 56, 60: return .shift + case 58, 61: return .option + case 59, 62: return .control + case 63: return .function + default: return [] + } + } +} + +extension NSEvent.ModifierFlags { + var cgEventRawValue: UInt64 { + var raw: UInt64 = 0 + if contains(.control) { raw |= CGEventFlags.maskControl.rawValue } + if contains(.option) { raw |= CGEventFlags.maskAlternate.rawValue } + if contains(.shift) { raw |= CGEventFlags.maskShift.rawValue } + if contains(.command) { raw |= CGEventFlags.maskCommand.rawValue } + return raw + } +} diff --git a/Sources/TinyTTS/UI/SettingsView.swift b/Sources/TinyTTS/UI/SettingsView.swift new file mode 100644 index 0000000..88bf82e --- /dev/null +++ b/Sources/TinyTTS/UI/SettingsView.swift @@ -0,0 +1,359 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @State private var tab: Tab = .general + + enum Tab: Hashable { + case general, hotkey, speech, about + } + + var body: some View { + TabView(selection: $tab) { + GeneralPane() + .tabItem { Label("通用", systemImage: "gearshape") } + .tag(Tab.general) + HotkeyPane() + .tabItem { Label("快捷键", systemImage: "command") } + .tag(Tab.hotkey) + SpeechPane() + .tabItem { Label("语音", systemImage: "waveform") } + .tag(Tab.speech) + AboutPane() + .tabItem { Label("关于", systemImage: "info.circle") } + .tag(Tab.about) + } + .frame(width: 540, height: 430) + .onReceive(NotificationCenter.default.publisher(for: .tinytssOpenSettings)) { _ in + // 保持当前 tab + } + } +} + +// MARK: - 通用 + +struct GeneralPane: View { + @ObservedObject var prefs = Prefs.shared + @State private var launchAtLogin = LaunchAtLogin.isEnabled + @State private var launchMessage: String? + + var body: some View { + Form { + Section { + Toggle("开机自动启动", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { _, newValue in + if let error = LaunchAtLogin.set(newValue) { + launchMessage = error + launchAtLogin = LaunchAtLogin.isEnabled + } else { + launchMessage = nil + } + } + if let launchMessage { + Text(launchMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("启动") + } + + Section { + Toggle("菜单栏只显示图标", isOn: $prefs.compactIcon) + Toggle("字幕固定在屏幕底部", isOn: $prefs.hudAtBottom) + Toggle("12 秒无声自动结束", isOn: $prefs.silenceTimeout) + Toggle("显示浮动字幕", isOn: $prefs.showHUD) + Toggle("开始和完成时播放提示音", isOn: $prefs.playSound) + } header: { + Text("界面") + } + + Section { + Picker("文字写入方式", selection: $prefs.insertMethod) { + ForEach(InsertMethod.allCases) { method in + Text(method.title).tag(method) + } + } + Text("「自动」会先尝试直接写入光标位置;不支持的应用(如部分网页输入框)自动改用粘贴。") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("写入") + } + } + .formStyle(.grouped) + } +} + +// MARK: - 快捷键 + +struct HotkeyPane: View { + @ObservedObject var prefs = Prefs.shared + @StateObject private var recorder = HotkeyRecorderModel() + + var body: some View { + Form { + Section { + HStack { + Text("当前快捷键") + Spacer() + Text(recorder.isRecording ? "请按下新的快捷键…" : prefs.hotkey.label) + .font(.system(.body, design: .rounded)) + .foregroundStyle(recorder.isRecording ? Color.accentColor : Color.primary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.06)) + ) + } + + HStack(spacing: 10) { + Button(recorder.isRecording ? "取消录制" : "重新录制") { + if recorder.isRecording { + recorder.end() + } else { + recorder.begin() + } + } + if recorder.isRecording { + Text("按 Esc 取消") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + + Text("可以设置单个修饰键(如右 Option),也可以设置组合键(如 ⌃Space)。设置组合键时先按住修饰键再按其他键。") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("触发方式") + } + + Section { + Picker("按键行为", selection: $prefs.triggerMode) { + ForEach(TriggerMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + Text("录音过程中按 Esc 可立即取消,文字不会写入。") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("模式") + } + } + .formStyle(.grouped) + .onDisappear { recorder.end() } + .onAppear { + recorder.onCapture = { spec in + prefs.hotkey = spec + } + } + } +} + +// MARK: - 语音 + +struct SpeechPane: View { + @ObservedObject var prefs = Prefs.shared + @ObservedObject var models = ModelManager.shared + @ObservedObject var dictation = DictationController.shared + + var body: some View { + Form { + Section { + LabeledContent("识别语言", value: "中文(普通话)") + Picker("识别引擎", selection: $prefs.engineChoice) { + Text("自动(全本地降级)").tag("auto") + Text("SpeechTranscriber").tag("speech") + Text("DictationTranscriber").tag("dictation") + Text("旧版本地识别").tag("legacy") + } + .disabled(dictation.state.isBusy || isDownloading) + .onChange(of: prefs.engineChoice) { _, _ in + SpeechEngine.resetWarm() + Task { await models.refresh(); SpeechEngine.warmUp() } + } + HStack { + Text("模型状态") + Spacer() + Text(models.statusText) + .foregroundStyle(models.state.isReady ? Color.secondary : Color.orange) + } + if case .downloading(let progress) = models.state { + ProgressView(value: progress) + } + if models.state != .installed && models.state != .legacy { + HStack { + Button("下载语音模型") { + Task { await models.download() } + } + .disabled(isDownloading) + Text("首次使用需要下载系统语音模型,只需要一次。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } header: { + Text("识别") + } + + Section { + HStack(spacing: 10) { + Button(dictation.state == .recording ? "结束测试" : "开始测试") { + if dictation.state == .recording { + dictation.stop() + } else { + dictation.testMode = true + dictation.start() + } + } + .disabled(dictation.state == .starting || dictation.state == .finalizing || (dictation.state.isBusy && !dictation.testMode)) + Text("测试不会写入任何输入框。") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + + Text(testText) + .font(.system(size: 14)) + .foregroundStyle(testText.isEmpty ? Color.secondary : Color.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.05)) + ) + .frame(minHeight: 72, alignment: .topLeading) + } header: { + Text("测试语音") + } + + Section { + Toggle("自动补句号", isOn: $prefs.autoPeriod) + Text("识别结果保留系统自带标点,只在你停顿时没有句末标点的情况下补一个句号。") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("后处理") + } + } + .formStyle(.grouped) + .onChange(of: dictation.state) { _, newValue in + if newValue == .idle { + dictation.testMode = false + } + } + } + + private var isDownloading: Bool { + if case .downloading = models.state { return true } + return false + } + + private var testText: String { + if dictation.state == .recording { + return dictation.liveText.isEmpty ? "正在听…" : dictation.liveText + } + return dictation.lastResult + } +} + +// MARK: - 关于 + +struct AboutPane: View { + @State private var micGranted = Permissions.microphone == .granted + @State private var axGranted = Permissions.accessibility + + var body: some View { + Form { + Section { + HStack { + Image(nsImage: NSApp.applicationIconImage) + .resizable().frame(width: 48, height: 48) + VStack(alignment: .leading, spacing: 2) { + Text("TinyTTS").font(.headline) + Text("轻量级语音输入 · 全部在本机识别") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(version).font(.caption).foregroundStyle(.secondary) + } + } + + Section { + PermissionRow( + title: "麦克风", + detail: micGranted ? "已授权" : "未授权,无法录音", + granted: micGranted, + actionTitle: "打开设置" + ) { + Permissions.openPrivacyPane("Privacy_Microphone") + } + + PermissionRow( + title: "辅助功能", + detail: axGranted ? "已授权" : "未授权,无法监听快捷键和写入文字", + granted: axGranted, + actionTitle: "请求授权" + ) { + Permissions.promptAccessibility() + Permissions.openPrivacyPane("Privacy_Accessibility") + } + + Text("辅助功能授权后会自动重试热键;麦克风授权在下次录音时重新检测。") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("权限") + } + + Section { + Button("打开日志目录") { + NSWorkspace.shared.open(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs")) + } + Text("快捷键 \(Prefs.shared.hotkey.label) · 语言 中文(普通话)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .onAppear { + micGranted = Permissions.microphone == .granted + axGranted = Permissions.accessibility + } + } + + private var version: String { + let v = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0" + return "v\(v)" + } +} + +struct PermissionRow: View { + var title: String + var detail: String + var granted: Bool + var actionTitle: String + var action: () -> Void + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(granted ? Color.green : Color.orange) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(title) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if !granted { + Button(actionTitle, action: action) + .controlSize(.small) + } + } + } +} diff --git a/Sources/TinyTTS/UI/SettingsWindowController.swift b/Sources/TinyTTS/UI/SettingsWindowController.swift new file mode 100644 index 0000000..cd34eac --- /dev/null +++ b/Sources/TinyTTS/UI/SettingsWindowController.swift @@ -0,0 +1,32 @@ +import AppKit +import SwiftUI + +@MainActor +final class SettingsWindowController: NSObject, NSWindowDelegate { + private var window: NSWindow? + + func windowWillClose(_ notification: Notification) { + window?.contentView = nil + window = nil + } + + func show() { + if window == nil { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 540, height: 430), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.delegate = self + window.title = "TinyTTS 设置" + window.isReleasedWhenClosed = false + window.contentView = NSHostingView(rootView: SettingsView()) + window.center() + self.window = window + } + + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } +} diff --git a/Sources/TinyTTS/UI/StatusItemController.swift b/Sources/TinyTTS/UI/StatusItemController.swift new file mode 100644 index 0000000..5ccdec5 --- /dev/null +++ b/Sources/TinyTTS/UI/StatusItemController.swift @@ -0,0 +1,121 @@ +import AppKit +import Combine + +@MainActor +final class StatusItemController: NSObject, NSMenuDelegate { + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let menu = NSMenu() + private let stateItem = NSMenuItem() + private let modelItem = NSMenuItem() + private let permissionItem = NSMenuItem() + private let toggleItem = NSMenuItem() + private let hintItem = NSMenuItem() + private let cancelItem = NSMenuItem(title: "取消此次听写", action: nil, keyEquivalent: "") + private var state: DictationController.State = .idle + private var animation: Timer? + private var frame = 0 + private var subscriptions = Set() + + var onToggle: (() -> Void)? + var onCancel: (() -> Void)? + var onSettings: (() -> Void)? + + override init() { + super.init() + menu.delegate = self + menu.autoenablesItems = false + for item in [stateItem, modelItem, permissionItem] { item.isEnabled = false; menu.addItem(item) } + menu.addItem(.separator()) + toggleItem.target = self + toggleItem.action = #selector(toggleTapped) + menu.addItem(toggleItem) + cancelItem.target = self + cancelItem.action = #selector(cancelTapped) + menu.addItem(cancelItem) + hintItem.isEnabled = false + menu.addItem(hintItem) + menu.addItem(.separator()) + for (title, code, action) in [ + ("设置与测试语音…", ",", #selector(settingsTapped)), + ("下载 / 检查语音模型", "", #selector(downloadTapped)), + ("复制上次识别结果", "", #selector(copyTapped)), + ("退出 TinyTTS", "q", #selector(quitTapped)) + ] { + let item = NSMenuItem(title: title, action: action, keyEquivalent: code) + item.target = self + menu.addItem(item) + } + statusItem.menu = menu + ModelManager.shared.$state.sink { [weak self] _ in + DispatchQueue.main.async { self?.refresh() } + }.store(in: &subscriptions) + Prefs.shared.objectWillChange.sink { [weak self] _ in + DispatchQueue.main.async { self?.refresh() } + }.store(in: &subscriptions) + update(state: .idle) + } + + func menuWillOpen(_ menu: NSMenu) { refresh() } + + func update(state: DictationController.State) { + self.state = state + animation?.invalidate() + animation = nil + if state.isBusy { + animation = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.frame += 1 + self.drawIcon() + } + } + } + refresh() + } + + private func refresh() { + let prefs = Prefs.shared + switch state { + case .idle: stateItem.title = "TinyTTS · 待机" + case .starting: stateItem.title = "TinyTTS · 正在准备…" + case .recording: stateItem.title = "TinyTTS · 正在聆听" + case .finalizing: stateItem.title = "TinyTTS · 正在整理文字…" + } + modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)" + permissionItem.title = "麦克风\(Permissions.microphone == .granted ? " ✓" : " 未授权") · 辅助功能\(Permissions.accessibility ? " ✓" : " 未授权")" + toggleItem.title = state == .recording ? "结束并写入" : "开始听写" + toggleItem.isEnabled = state == .idle || state == .recording + cancelItem.isEnabled = state.isBusy + hintItem.title = "\(prefs.hotkey.label) · " + (prefs.triggerMode == .hold ? "按住说话 · Esc 取消" : "按键切换 · Esc 取消") + drawIcon() + } + + private func drawIcon() { + guard let button = statusItem.button else { return } + let busy = state == .starting || state == .finalizing + let symbol = busy ? "arrow.trianglehead.2.clockwise.rotate.90" : (state == .recording ? "waveform" : "mic") + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: stateItem.title) + image?.isTemplate = true + button.image = image + button.imagePosition = .imageLeading + button.title = Prefs.shared.compactIcon ? "" : " TinyTTS" + button.contentTintColor = state == .recording ? .systemRed : nil + button.alphaValue = state == .recording ? 0.65 + 0.35 * sin(Double(frame) * 0.5) : 1 + button.wantsLayer = true + button.layer?.sublayerTransform = busy ? CATransform3DMakeRotation(CGFloat(frame) * .pi / 8, 0, 0, 1) : CATransform3DIdentity + button.toolTip = "\(stateItem.title)\n\(modelItem.title)\n\(hintItem.title)" + button.setAccessibilityLabel(button.toolTip) + } + + @objc private func copyTapped() { + let text = DictationController.shared.lastResult + guard !text.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + @objc private func toggleTapped() { onToggle?() } + @objc private func cancelTapped() { onCancel?() } + @objc private func settingsTapped() { onSettings?() } + @objc private func downloadTapped() { Task { await ModelManager.shared.refresh(); await ModelManager.shared.download() } } + @objc private func quitTapped() { NSApp.terminate(nil) } +} diff --git a/Sources/TinyTTS/main.swift b/Sources/TinyTTS/main.swift new file mode 100644 index 0000000..809883a --- /dev/null +++ b/Sources/TinyTTS/main.swift @@ -0,0 +1,13 @@ +import AppKit +import Foundation + +if CommandLine.arguments.contains("--selftest") { + MainActor.assumeIsolated { SelfTest.run() } +} + +MainActor.assumeIsolated { + let application = NSApplication.shared + let appDelegate = AppDelegate() + application.delegate = appDelegate + application.run() +} diff --git a/Tests/TinyTTSTests/CoreTests.swift b/Tests/TinyTTSTests/CoreTests.swift new file mode 100644 index 0000000..b572561 --- /dev/null +++ b/Tests/TinyTTSTests/CoreTests.swift @@ -0,0 +1,176 @@ +import Testing +import Foundation +import AVFoundation +@testable import TinyTTS + +struct TextTests { + @Test func punctuationAndWhitespace() { + #expect(TextPostProcessor.finalize("今天开会", addPeriod: true) == "今天开会。") + #expect(TextPostProcessor.finalize("已经结束!", addPeriod: true) == "已经结束!") + #expect(TextPostProcessor.finalize("他说“你好。”", addPeriod: true) == "他说“你好。”") + #expect(TextPostProcessor.finalize("保持 两个空格", addPeriod: false) == "保持 两个空格") + #expect(TextPostProcessor.finalize("嗯,那个,下午开会", addPeriod: false) == "嗯,那个,下午开会") + #expect(TextPostProcessor.finalize("\n \t", addPeriod: true).isEmpty) + } + + @Test func shortcutRoundTrip() throws { + let spec = HotkeySpec.make(kind: .combo, keyCode: 49, modifiers: 1 << 19) + #expect(spec.label == "⌥Space") + #expect(try JSONDecoder().decode(HotkeySpec.self, from: JSONEncoder().encode(spec)) == spec) + #expect(HotkeySpec.rightOption.keyCode == 61) + #expect(HotkeySpec.modifierName(forKeyCode: 58) == "左 Option") + } + + @Test func audioLevelAndConversion() throws { + let source = AVAudioFormat(standardFormatWithSampleRate: 48000, channels: 1)! + let target = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)! + let buffer = AVAudioPCMBuffer(pcmFormat: source, frameCapacity: 1024)! + buffer.frameLength = 1024 + buffer.floatChannelData![0].initialize(repeating: 0, count: 1024) + #expect(AudioMath.level(of: buffer) == 0) + buffer.floatChannelData![0].update(repeating: 0.2, count: 1024) + #expect(AudioMath.level(of: buffer) > 0.5) + let converted = try #require(AudioMath.convert(buffer, using: AVAudioConverter(from: source, to: target)!, to: target)) + #expect(converted.frameLength > 0) + #expect(converted.format.sampleRate == 16000) + } + + @Test func timeoutDoesNotWaitForUncooperativeTask() async { + let start = Date() + let value: Int? = await withTimeout(0.02) { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { continuation.resume(returning: 42) } + } + } + #expect(value == nil) + #expect(Date().timeIntervalSince(start) < 0.3) + #expect(await withTimeout(1) { 7 } == 7) + } +} + +@MainActor +private final class FakeSession: RecognitionSession { + var onTextChanged: ((String) -> Void)? + var onAudioLevel: ((Float) -> Void)? + var onFailure: ((String) -> Void)? + var cancelled = false + var finishDelay = false + var failStart = false + func start() throws { + if failStart { throw NSError(domain: "test", code: 1) } + } + func finish() async -> String { + if finishDelay { try? await Task.sleep(for: .milliseconds(80)) } + return "测试" + } + func cancel() async { cancelled = true } +} + +@Suite(.serialized) +@MainActor +struct SessionTests { + private func settle() async { try? await Task.sleep(for: .milliseconds(30)) } + + @Test func cancelWhileStartingThenRestart() async { + let old = FakeSession() + let fresh = FakeSession() + var calls = 0 + let controller = DictationController(acquire: { + calls += 1 + if calls == 1 { try? await Task.sleep(for: .milliseconds(100)); return old } + return fresh + }, microphone: { true }, secureInput: { false }, replenish: {}) + controller.testMode = true + controller.start() + await settle() + #expect(controller.state == .starting) + controller.stop() // release before preparation finishes + #expect(controller.state == .idle) + controller.testMode = true + controller.start() + await settle() + #expect(controller.state == .recording) + try? await Task.sleep(for: .milliseconds(120)) + #expect(old.cancelled) + #expect(controller.state == .recording) + controller.cancel() + } + + @Test func cancelDuringFinalizationDiscardsResult() async { + let fake = FakeSession() + fake.finishDelay = true + let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {}) + controller.testMode = true + controller.start() + await settle() + #expect(controller.state == .recording) + controller.stop() + #expect(controller.state == .finalizing) + controller.cancel() + try? await Task.sleep(for: .milliseconds(150)) + #expect(controller.state == .idle) + #expect(controller.lastResult.isEmpty) + #expect(!controller.testMode) + } + + @Test func testModeAndStartFailureCleanup() async { + let fake = FakeSession() + fake.failStart = true + let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {}) + controller.testMode = true + controller.start() + await settle() + #expect(controller.state == .idle) + #expect(fake.cancelled) + #expect(!controller.testMode) + } + + @Test func successfulTestKeepsTextWithoutInsertion() async { + let fake = FakeSession() + let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {}) + controller.testMode = true + controller.start() + await settle() + try? await Task.sleep(for: .milliseconds(400)) + controller.stop() + await settle() + #expect(controller.state == .idle) + #expect(controller.lastResult == "测试。") + #expect(!controller.testMode) + } + + @Test func microphoneDenialDoesNotAcquireSession() async { + var acquired = false + let controller = DictationController(acquire: { acquired = true; return FakeSession() }, microphone: { false }, secureInput: { false }, replenish: {}) + controller.testMode = true + controller.start() + await settle() + #expect(!acquired) + #expect(controller.state == .idle) + #expect(!controller.testMode) + } + + @Test func recognitionFailureCancelsWithoutInsertion() async { + let fake = FakeSession() + let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {}) + var error: String? + controller.onError = { error = $0 } + controller.testMode = true + controller.start() + await settle() + fake.onFailure?("model stopped") + await settle() + #expect(controller.state == .idle) + #expect(fake.cancelled) + #expect(error?.contains("model stopped") == true) + #expect(controller.lastResult.isEmpty) + } + + @Test func secureInputNeverStartsCapture() { + var acquired = false + let controller = DictationController(acquire: { acquired = true; return FakeSession() }, microphone: { true }, secureInput: { true }, replenish: {}) + controller.start() + #expect(controller.state == .idle) + #expect(!acquired) + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..e325586 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 构建 TinyTTS.app:swift build → 组装 bundle → 签名 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-$ROOT/.build/ModuleCache}" +export SWIFTPM_MODULECACHE_OVERRIDE="$CLANG_MODULE_CACHE_PATH" +CONFIG="${1:-release}" +echo "==> 编译 ${CONFIG}" +swift build -c "${CONFIG}" --disable-sandbox + +BIN="$ROOT/.build/${CONFIG}/TinyTTS" +APP="$ROOT/dist/TinyTTS.app" + +echo "==> 组装 bundle" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/TinyTTS" +cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist" +cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" + +# 优先使用显式指定 > Apple Development > 本地自签名 > 临时签名。 +# 固定身份很关键:否则每次重建都会让「辅助功能」授权失效。 +resolve_identity() { + if [ -n "${TINYTTS_SIGN_IDENTITY:-}" ]; then + echo "${TINYTTS_SIGN_IDENTITY}" + return + fi + local valid + valid="$(security find-identity -v -p codesigning 2>/dev/null || true)" + if echo "${valid}" | grep -q "Apple Development"; then + echo "${valid}" | grep "Apple Development" | head -1 | sed -E 's/.*"(.*)"/\1/' + return + fi + if echo "${valid}" | grep -q "TinyTTS Dev"; then + echo "TinyTTS Dev" + return + fi + echo "" +} + +IDENTITY="$(resolve_identity)" + +echo "==> 签名" +if [ -n "${IDENTITY}" ]; then + codesign --force --sign "${IDENTITY}" --timestamp=none "$APP" >/dev/null 2>&1 \ + || codesign --force --sign "${IDENTITY}" "$APP" + echo "签名身份:${IDENTITY}" +else + codesign --force --sign - "$APP" >/dev/null + echo "⚠️ 只找到临时签名,重建后「辅助功能」授权会失效。" + echo " 先运行 ./scripts/setup-signing.sh 可生成固定身份。" +fi + +codesign --verify --strict "$APP" && echo "==> 签名校验通过" +echo "==> 产物:$APP" diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift new file mode 100644 index 0000000..52141ca --- /dev/null +++ b/scripts/make-icon.swift @@ -0,0 +1,199 @@ +#!/usr/bin/env swift +// +// make-icon.swift — 生成 TinyTTS 应用图标 +// +// 设计:白色麦克风由三段「文字条」镂空而成 —— 声音落成文字。 +// 深靛蓝渐变底 + 纯白图形,16pt 到 1024pt 都保持清晰轮廓。 +// +// 用法:swift scripts/make-icon.swift <输出目录> +// + +import AppKit +import Foundation + +let BASE: CGFloat = 1024 + +/// 超椭圆(squircle),n 越大越接近方形;n=4 接近 Apple 图标的连续曲率 +func squirclePath(in rect: NSRect, n: CGFloat = 4.0, samples: Int = 720) -> NSBezierPath { + let path = NSBezierPath() + let a = rect.width / 2 + let b = rect.height / 2 + for i in 0...samples { + let t = CGFloat(i) / CGFloat(samples) * 2 * .pi + let ct = cos(t) + let st = sin(t) + let x = rect.midX + a * (ct < 0 ? -1 : 1) * pow(abs(ct), 2 / n) + let y = rect.midY + b * (st < 0 ? -1 : 1) * pow(abs(st), 2 / n) + if i == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } + } + path.close() + return path +} + +func drawIcon(size: CGFloat) -> NSBitmapImageRep { + let px = Int(size.rounded()) + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0 + )! + rep.size = NSSize(width: px, height: px) + + NSGraphicsContext.saveGraphicsState() + guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return rep } + NSGraphicsContext.current = ctx + ctx.imageInterpolation = .high + + let s = CGFloat(px) / BASE + // 小尺寸线条会糊,做一点加粗补偿 + let bold: CGFloat = px <= 32 ? 1.16 : (px <= 64 ? 1.06 : 1.0) + // 尺寸分级:越小越简化,否则 16/32px 会糊成一团 + let isTiny = px <= 32 + let isSmall = px <= 64 + let plateInset: CGFloat = isTiny ? 52 : (isSmall ? 72 : 100) + let glyphScale: CGFloat = isTiny ? 1.34 : (isSmall ? 1.20 : 1.10) + let showHoles = px >= 128 // 镂空文字在小图上会糊成一格一格 + let showGlyphShadow = px >= 128 + let showPlateShadow = px >= 128 + + func len(_ v: CGFloat) -> CGFloat { v * s } + /// 1024 基准坐标,y 按「距顶部」度量 + func P(_ x: CGFloat, _ yTop: CGFloat) -> NSPoint { NSPoint(x: x * s, y: (BASE - yTop) * s) } + /// 矩形(x, 距顶 y, 宽, 高) + func R(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat) -> NSRect { + NSRect(x: len(x), y: (BASE - yTop - h) * s, width: len(w), height: len(h)) + } + func roundRect(_ x: CGFloat, _ yTop: CGFloat, _ w: CGFloat, _ h: CGFloat, _ radius: CGFloat) -> NSBezierPath { + NSBezierPath(roundedRect: R(x, yTop, w, h), xRadius: len(radius), yRadius: len(radius)) + } + + // MARK: 底板 + + let inset = len(plateInset) + let plate = NSRect(x: inset, y: inset, + width: CGFloat(px) - inset * 2, + height: CGFloat(px) - inset * 2) + let shape = squirclePath(in: plate) + + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.30) + shadow.shadowBlurRadius = len(24) + shadow.shadowOffset = NSSize(width: 0, height: -len(9)) + if showPlateShadow { shadow.set() } + + let topColor = NSColor(srgbRed: 0.38, green: 0.50, blue: 1.00, alpha: 1) // #617FFF + let bottomColor = NSColor(srgbRed: 0.21, green: 0.19, blue: 0.75, alpha: 1) // #3630BF + NSGradient(starting: topColor, ending: bottomColor)?.draw(in: shape, angle: -90) + + NSShadow().set() // 之后不再带投影 + + // 顶部柔光:整块区域线性渐变,避免出现横向硬边 + NSGraphicsContext.saveGraphicsState() + shape.addClip() + NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.20), + NSColor.white.withAlphaComponent(0.0), + ])?.draw(in: plate, angle: -90) + NSGraphicsContext.restoreGraphicsState() + + NSColor.white.withAlphaComponent(0.10).setStroke() + shape.lineWidth = max(len(2), 0.5) + shape.stroke() + + // MARK: 麦克风 + + let glyphShadow = NSShadow() + glyphShadow.shadowColor = NSColor.black.withAlphaComponent(0.20) + glyphShadow.shadowBlurRadius = len(14) + glyphShadow.shadowOffset = NSSize(width: 0, height: -len(7)) + if showGlyphShadow { glyphShadow.set() } + + NSColor.white.setFill() + NSColor.white.setStroke() + + // 腔体:胶囊上挖出三条文字 + let capX: CGFloat = 412, capW: CGFloat = 200 + let capY: CGFloat = 225, capH: CGFloat = 300 + NSGraphicsContext.current?.cgContext.concatenate(CGAffineTransform(a: glyphScale, b: 0, c: 0, d: glyphScale, tx: len(512) * (1 - glyphScale), ty: len(512) * (1 - glyphScale))) + let body = roundRect(capX, capY, capW, capH, capW / 2) + + let lineX: CGFloat = 452 + let lineH: CGFloat = 24 * bold + let lineY: [CGFloat] = [305, 363, 421] + let linePath = NSBezierPath() + for (i, y) in lineY.enumerated() { + let w: CGFloat = [120, 88, 104][i] + linePath.append(roundRect(lineX, y, w, lineH, lineH / 2)) + } + + let bodyWithHoles = NSBezierPath() + bodyWithHoles.append(body) + if showHoles { bodyWithHoles.append(linePath) } + bodyWithHoles.windingRule = .evenOdd + bodyWithHoles.fill() + + // 托架 + let arc = NSBezierPath() + arc.appendArc(withCenter: P(512, 525), radius: len(148), + startAngle: 180, endAngle: 360, clockwise: false) + arc.lineWidth = len(54 * bold) + arc.lineCapStyle = .round + arc.stroke() + + // 立柱 + roundRect(512 - 27, 660, 54, 92, 27).fill() + + // 底座 + roundRect(402, 745, 220, 54 * bold, 27 * bold).fill() + + NSShadow().set() + NSGraphicsContext.restoreGraphicsState() + return rep +} + +// MARK: - 输出 + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "Resources" +let fm = FileManager.default +let iconset = (outDir as NSString).appendingPathComponent("AppIcon.iconset") + +try? fm.removeItem(atPath: iconset) +try fm.createDirectory(atPath: iconset, withIntermediateDirectories: true) + +let variants: [(String, Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] + +for (name, px) in variants { + let rep = drawIcon(size: CGFloat(px)) + guard let data = rep.representation(using: .png, properties: [:]) else { + FileHandle.standardError.write("生成 \(name) 失败\n".data(using: .utf8)!) + exit(1) + } + try data.write(to: URL(fileURLWithPath: iconset).appendingPathComponent("\(name).png")) +} + +let icns = (outDir as NSString).appendingPathComponent("AppIcon.icns") +let proc = Process() +proc.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +proc.arguments = ["-c", "icns", iconset, "-o", icns] +try proc.run() +proc.waitUntilExit() + +// 留一份 1024 PNG,供文档 / 网页 / 预览使用 +try? fm.removeItem(atPath: (outDir as NSString).appendingPathComponent("AppIcon-1024.png")) +try? fm.copyItem( + atPath: (iconset as NSString).appendingPathComponent("icon_512x512@2x.png"), + toPath: (outDir as NSString).appendingPathComponent("AppIcon-1024.png") +) + +guard proc.terminationStatus == 0 else { + FileHandle.standardError.write("iconutil 失败:\(proc.terminationStatus)\n".data(using: .utf8)!) + exit(1) +} +print("已生成 \(icns)") diff --git a/scripts/setup-signing.sh b/scripts/setup-signing.sh new file mode 100755 index 0000000..5985e8f --- /dev/null +++ b/scripts/setup-signing.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# 生成一个本地自签名代码签名证书,保证每次重建后「辅助功能」授权不会失效。 +set -euo pipefail + +IDENTITY="${TINYTTS_SIGN_IDENTITY:-TinyTTS Dev}" + +if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then + echo "签名身份已存在:$IDENTITY" + exit 0 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/cert.cnf" <<'EOF' +[req] +distinguished_name = dn +x509_extensions = ext +prompt = no +[dn] +CN = TinyTTS Dev +[ext] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = critical,codeSigning +EOF + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$WORK/key.pem" -out "$WORK/cert.pem" \ + -days 3650 -config "$WORK/cert.cnf" >/dev/null 2>&1 + +openssl pkcs12 -export -out "$WORK/id.p12" \ + -inkey "$WORK/key.pem" -in "$WORK/cert.pem" \ + -name "$IDENTITY" -passout pass:tinytss \ + -legacy -macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1 \ + || openssl pkcs12 -export -out "$WORK/id.p12" \ + -inkey "$WORK/key.pem" -in "$WORK/cert.pem" \ + -name "$IDENTITY" -passout pass:tinytss \ + -macalg sha1 -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES >/dev/null 2>&1 + +security import "$WORK/id.p12" -k "$HOME/Library/Keychains/login.keychain-db" \ + -P tinytss -T /usr/bin/codesign >/dev/null + +echo "已创建签名身份:$IDENTITY" +security find-identity -v -p codesigning | grep "$IDENTITY" || true diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..6614017 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-$ROOT/.build/ModuleCache}" +export SWIFTPM_MODULECACHE_OVERRIDE="$CLANG_MODULE_CACHE_PATH" +swift test --disable-sandbox