feat: build TinyTTS local macOS menu bar dictation app
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
.build/
|
||||
.workbuddy/
|
||||
PLAN.md
|
||||
TESTING.md
|
||||
@@ -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.
|
||||
@@ -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)])
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
<p align="center">
|
||||
<img src="Resources/AppIcon-1024.png" width="128" alt="TinyTTS logo">
|
||||
</p>
|
||||
|
||||
# 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 提供,不随本项目再分发。
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 838 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>TinyTTS</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.eccstartup.tinystt</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>TinyTTS</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TinyTTS</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>TinyTTS 需要用麦克风把你说的话转成文字。</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>TinyTTS 在本机完成语音识别,音频不会上传到任何服务器。</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>TinyTTS</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CGEvent>? {
|
||||
if let refcon {
|
||||
let monitor = Unmanaged<HotkeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
|
||||
monitor.handle(type: type, event: event)
|
||||
}
|
||||
return Unmanaged.passUnretained(event)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<AnalyzerInput>.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)")
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@MainActor
|
||||
enum SpeechEngine {
|
||||
private static var warm: (any RecognitionSession)?
|
||||
private static var warming: Task<Void, Never>?
|
||||
private static var expiry: Task<Void, Never>?
|
||||
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() }
|
||||
}
|
||||
}
|
||||
@@ -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<AnalyzerInput>
|
||||
private let continuation: AsyncStream<AnalyzerInput>.Continuation
|
||||
|
||||
private var analyzerTask: Task<Void, Never>?
|
||||
private var resultsTask: Task<Void, Never>?
|
||||
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<AnalyzerInput>,
|
||||
continuation: AsyncStream<AnalyzerInput>.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<AnalyzerInput>.Continuation!
|
||||
let stream = AsyncStream<AnalyzerInput>(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<T>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<T?, Never>?
|
||||
init(_ continuation: CheckedContinuation<T?, Never>) { self.continuation = continuation }
|
||||
func resolve(_ value: T?) {
|
||||
lock.lock()
|
||||
let current = continuation
|
||||
continuation = nil
|
||||
lock.unlock()
|
||||
current?.resume(returning: value)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func withTimeout<T>(_ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// 文本后处理:保留系统标点,不清洗,只在缺少句末标点时补句号。
|
||||
enum TextPostProcessor {
|
||||
private static let terminalPunctuation: Set<Character> = [
|
||||
"。", "!", "?", "…", ";", ":", ".", "!", "?", ";", ":", "\n"
|
||||
]
|
||||
|
||||
private static let closingMarks: Set<Character> = ["”", "\"", "」", "』", ")", ")", "》", "】", "’", "'"]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<AnyCancellable>()
|
||||
|
||||
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) }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
@@ -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
|
||||