feat: build TinyTTS local macOS menu bar dictation app

This commit is contained in:
eccstartup committed 2026-10-23 10:16:59 +08:00
commit f39b3f9137
43 files changed
+3197

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.DS_Store
dist/
.build/
.workbuddy/
PLAN.md
TESTING.md
+21
View File
@@ -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.
+18
View File
@@ -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)])
]
)
+101
View File
@@ -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 提供,不随本项目再分发。
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+38
View File
@@ -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>
+144
View File
@@ -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()
}
}
}
+163
View File
@@ -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)
}
+86
View File
@@ -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)
}
}
+117
View File
@@ -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)")
}
}
+103
View File
@@ -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)"
}
}
}
+105
View File
@@ -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 }
}
+62
View File
@@ -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() }
}
}
+270
View File
@@ -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
}
}
}
+49
View File
@@ -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
}
}
}
+42
View File
@@ -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)
}
}
+120
View File
@@ -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")
}
+180
View File
@@ -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
}
}
+204
View File
@@ -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 }
}
+85
View File
@@ -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
}
}
+359
View File
@@ -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) }
}
+13
View File
@@ -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()
}
+176
View File
@@ -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)
}
}
+58
View File
@@ -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"
+199
View File
@@ -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)")
+45
View File
@@ -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
+7
View File
@@ -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