fix: stabilize dictation hotkeys and local engine fallback

This commit is contained in:
eccstartup committed 2026-10-23 10:29:40 +08:00
1 parent f39b3f9137
commit fe4af6b34a
16 files changed
+379 -43

No files matched your search

+6 -1
View File
@@ -52,7 +52,11 @@ TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
1. 启动 app,允许麦克风,在「隐私与安全性 → 辅助功能」中允许 TinyTTS。若系统提示输入监控,也需在相应页面授权。辅助功能授权后会自动重试热键。
2. 打开「设置与测试语音 → 语音」,检查模型状态。首次下载需要网络,下载完成后的识别在本机进行;失败时可重试。
3. 点「开始测试」检查实时识别,结束后查看结果。此模式不向任何应用写入文字。
4. 回到目标输入框,按右 Option,说话,再按一次结束。
4. 回到目标输入框,按右 Option;首次若显示「正在准备本地识别」,等变为「聆听中」再说话,再按一次结束并写入。
「模型已安装」不代表引擎已经就绪,菜单和语音设置会分别显示两种状态。自动模式遇到分析器加载失败时,会记住本进程的降级结果,避免每次录音重新等待失败引擎。旧版本地引擎首次使用需单独允许系统「语音识别」权限;授权期间不会显示正在录音。
若没有文字,可用语音页「检查本地识别(合成语音)」区分本地引擎故障与麦克风输入问题。该检查使用固定测试文本,不采集麦克风,也不写入其他应用。
设置有通用、快捷键、语音、关于四个页面。语音页可在自动、本地 SpeechTranscriber、本地 DictationTranscriber 和旧版本地识别间选择。自动模式优先 SpeechTranscriber,不支持时降级;旧版强制 `requiresOnDeviceRecognition`,额外需要系统语音识别权限,缺少本地能力时不会转为云端识别。
@@ -73,6 +77,7 @@ TINYTTS_SIGN_IDENTITY='Apple Development: Your Name (TEAMID)' ./scripts/build.sh
# 用真实本地模型识别系统合成语音,需先安装模型
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --dictation
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --legacy
```
自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。真实识别与跨应用上屏仍需在系统语音服务正常时完成验收。
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.1.2</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>LSUIElement</key>
+1 -1
View File
@@ -13,7 +13,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
Log.info("launched, hotkey=\(prefs.hotkey.label), mode=\(prefs.triggerMode.rawValue)")
Log.info("launched v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), 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
+22 -2
View File
@@ -50,6 +50,7 @@ final class DictationController: ObservableObject {
private var targetPID: pid_t?
private var silenceTask: Task<Void, Never>?
private var lastVoice = Date()
private var recognitionError: String?
private var hud: HUDController?
func attach(hud: HUDController) {
@@ -73,6 +74,7 @@ final class DictationController: ObservableObject {
targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier
state = .starting
liveText = ""
recognitionError = nil
if !testMode, prefs.showHUD {
hud?.show()
}
@@ -83,6 +85,7 @@ final class DictationController: ObservableObject {
guard await microphone() else {
throw NSError(domain: "TinyTTS", code: 1, userInfo: [NSLocalizedDescriptionKey: "请在系统设置中授权麦克风。 "])
}
guard generation == token, state == .starting else { return }
let session = try await acquire()
Log.info("session acquired in \(Int(Date().timeIntervalSince(t0) * 1000))ms")
guard generation == token, state == .starting else {
@@ -90,6 +93,9 @@ final class DictationController: ObservableObject {
return
}
self.session = session
try await session.authorize()
guard generation == token, state == .starting else { await session.cancel(); return }
Log.info("recognition backend=\(type(of: session))")
var firstText = true
session.onTextChanged = { [weak self] text in
@@ -101,14 +107,21 @@ final class DictationController: ObservableObject {
self?.liveText = text
self?.hud?.update(text: text)
}
var receivedAudio = false
var detectedVoice = false
session.onAudioLevel = { [weak self] level in
guard let self, self.generation == token else { return }
if !receivedAudio { receivedAudio = true; Log.info("microphone audio buffers active") }
if level > 0.18, !detectedVoice { detectedVoice = true; Log.info("microphone detected non-silent input") }
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 }
guard let self, self.generation == token else { return }
self.recognitionError = message
if self.state == .finalizing { return }
guard self.state == .recording else { return }
self.cancel()
self.onError?("本地识别失败:" + message)
}
@@ -117,6 +130,7 @@ final class DictationController: ObservableObject {
startedAt = Date()
Log.info("recording started (total \(Int(Date().timeIntervalSince(t0) * 1000))ms)")
state = .recording
hud?.setListening()
lastVoice = Date()
silenceTask = Task { [weak self] in
while !Task.isCancelled {
@@ -173,13 +187,18 @@ final class DictationController: ObservableObject {
if wasTest {
self.state = .idle
if text.isEmpty, duration >= 0.35 {
self.onError?(self.recognitionError.map { "本地识别失败:" + $0 } ?? "测试未识别到文字。请检查输入设备,并运行语音页的本地识别诊断。")
}
return
}
if text.isEmpty {
Log.info("empty result, skipped")
NSSound(named: "Basso")?.play()
self.state = .idle
if duration >= 0.35 {
self.onError?(self.recognitionError.map { "本地识别失败:" + $0 } ?? "本次没有识别到文字。请检查麦克风输入;可以在设置 → 语音中测试。")
}
return
}
@@ -196,6 +215,7 @@ final class DictationController: ObservableObject {
func cancel() {
guard state.isBusy else { return }
Log.info("session cancelled, state=\(state)")
generation = UUID()
silenceTask?.cancel()
testMode = false
+13 -12
View File
@@ -15,12 +15,14 @@ final class HotkeyMonitor {
private var tap: CFMachPort?
private var source: CFRunLoopSource?
private var spec: HotkeySpec
private let secureInput: () -> Bool
private var modifierDown = false
private var comboDown = false
var suspended = false
init(spec: HotkeySpec) {
init(spec: HotkeySpec, secureInput: @escaping () -> Bool = { Inserter.isSecureEventInputEnabled() }) {
self.spec = spec
self.secureInput = secureInput
}
deinit {
@@ -34,6 +36,7 @@ final class HotkeyMonitor {
func update(spec: HotkeySpec) {
self.spec = spec
modifierDown = false
comboDown = false
}
@discardableResult
@@ -77,8 +80,9 @@ final class HotkeyMonitor {
source = nil
}
fileprivate func handle(type: CGEventType, event: CGEvent) {
func handle(type: CGEventType, event: CGEvent) {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
Log.error("hotkey tap disabled type=\(type.rawValue)")
modifierDown = false
comboDown = false
fire(.cancel)
@@ -89,7 +93,7 @@ final class HotkeyMonitor {
}
guard !suspended else { return }
if Inserter.isSecureEventInputEnabled() {
if secureInput() {
if modifierDown || comboDown { fire(.cancel) }
modifierDown = false
comboDown = false
@@ -106,7 +110,8 @@ final class HotkeyMonitor {
switch spec.kind {
case .modifier:
guard type == .flagsChanged, keyCode == spec.keyCode else { return }
let down = CGEventSource.keyState(.combinedSessionState, key: CGKeyCode(spec.keyCode))
let down = spec.isPressed(in: event.flags)
Log.info("modifier event code=\(keyCode) flags=\(event.flags.rawValue) down=\(down)")
if down, !modifierDown {
modifierDown = true
fire(.pressed)
@@ -138,15 +143,11 @@ final class HotkeyMonitor {
}
private func fire(_ event: Event) {
// tap 回调在 tap 所在线程(主线程)执行,仍需保证 UI 操作在主线程
if Thread.isMainThread {
handler?(event)
} else {
DispatchQueue.main.async { [weak self] in
self?.handler?(event)
}
}
// Never run AX, HUD creation or microphone start inside the event-tap
// callback: a slow handler causes macOS to disable the tap.
DispatchQueue.main.async { [weak self] in self?.handler?(event) }
}
}
private func hotkeyCallback(
+19
View File
@@ -23,6 +23,25 @@ struct HotkeySpec: Codable, Equatable {
HotkeySpec.flag(forKeyCode: keyCode)
}
/// Use the event's device-side flags. A global keyState query can already
/// reflect key-up when a listen-only tap receives a short modifier press.
func isPressed(in flags: CGEventFlags) -> Bool {
let pair: (own: UInt64, both: UInt64)
switch keyCode {
case 61: pair = (0x40, 0x60) // NX_DEVICERALTKEYMASK
case 58: pair = (0x20, 0x60)
case 54: pair = (0x10, 0x18)
case 55: pair = (0x08, 0x18)
case 60: pair = (0x04, 0x06)
case 56: pair = (0x02, 0x06)
case 62: pair = (0x2000, 0x2001)
case 59: pair = (0x01, 0x2001)
default: return flags.contains(modifierFlag)
}
if flags.rawValue & pair.both != 0 { return flags.rawValue & pair.own != 0 }
return flags.contains(modifierFlag)
}
static func flag(forKeyCode code: Int) -> CGEventFlags {
switch code {
case 54, 55: return .maskCommand
+50
View File
@@ -33,6 +33,7 @@ enum SelfTest {
@MainActor
private static func perform() async throws {
if CommandLine.arguments.contains("--legacy") { try await performLegacy(); return }
let useDictation = CommandLine.arguments.contains("--dictation")
let locale = Locale(identifier: "zh-CN")
let expected = "今天的天气不错,我们下午三点开会。"
@@ -114,4 +115,53 @@ enum SelfTest {
}
print("SELFTEST PASSED (synthesized audio only)")
}
@MainActor
static func performLegacy() async throws {
let status = SFSpeechRecognizer.authorizationStatus()
print("Legacy authorization: \(status.rawValue)")
guard status == .authorized else { throw failure("请先在 app 中选择旧版本地识别并授权,再运行自检。") }
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")),
recognizer.supportsOnDeviceRecognition, recognizer.isAvailable else {
throw failure("旧版本地引擎不可用。")
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("tinystt-legacy-\(UUID()).aiff")
defer { try? FileManager.default.removeItem(at: url) }
let say = Process()
say.executableURL = URL(fileURLWithPath: "/usr/bin/say")
say.arguments = ["-v", "Tingting", "-o", url.path, "今天的天气不错,我们下午三点开会。"]
try say.run()
say.waitUntilExit()
guard say.terminationStatus == 0 else { throw failure("合成语音失败。") }
let request = SFSpeechURLRecognitionRequest(url: url)
request.requiresOnDeviceRecognition = true
request.shouldReportPartialResults = true
var recognition: SFSpeechRecognitionTask?
let result: String? = await withTimeout(20) {
await withCheckedContinuation { continuation in
let gate = LegacyTestResult(continuation)
recognition = recognizer.recognitionTask(with: request) { result, error in
if let result, result.isFinal { gate.finish(result.bestTranscription.formattedString) }
if let error { print("Legacy error: \(error)"); gate.finish("") }
}
}
}
recognition?.cancel()
print("Legacy recognized: \(result ?? "<timeout>")")
guard let result, result.contains("天气"), result.contains("开会") else { throw failure("旧版本地识别没有返回预期文本。") }
print("SELFTEST PASSED (legacy synthesized audio only)")
}
}
private final class LegacyTestResult: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<String, Never>?
init(_ continuation: CheckedContinuation<String, Never>) { self.continuation = continuation }
func finish(_ text: String) {
lock.lock()
let current = continuation
continuation = nil
lock.unlock()
current?.resume(returning: text)
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ final class ModelManager: ObservableObject {
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 }
guard let selected = try? await SpeechBackend.module(locale: locale, choice: Prefs.shared.engineChoice) else { state = .unsupported; return }
let status = await AssetInventory.status(forModules: [selected])
Log.info("model status for \(locale.identifier): \(status)")
switch status {
@@ -56,7 +56,7 @@ final class ModelManager: ObservableObject {
defer { running = false }
let locale = SpeechEngine.currentLocale
guard let module = try? await SpeechBackend.module(locale: locale) else { state = .unsupported; return }
guard let module = try? await SpeechBackend.module(locale: locale, choice: Prefs.shared.engineChoice) else { state = .unsupported; return }
do {
// 预留名额,避免模型被系统回收
@@ -93,7 +93,7 @@ final class ModelManager: ObservableObject {
switch state {
case .legacy: "旧版本地引擎(模型由系统听写管理)"
case .unknown: "检查中…"
case .installed: "已就绪"
case .installed: "模型已安装"
case .needsDownload: "需要下载模型"
case .downloading(let p): "下载中 \(Int(p * 100))%"
case .unsupported: "当前系统不支持该语言"
+23 -7
View File
@@ -6,15 +6,19 @@ protocol RecognitionSession: AnyObject {
var onTextChanged: ((String) -> Void)? { get set }
var onAudioLevel: ((Float) -> Void)? { get set }
var onFailure: ((String) -> Void)? { get set }
func authorize() async throws
func start() throws
func finish() async -> String
func cancel() async
}
@MainActor
extension RecognitionSession {
func authorize() async throws {}
}
enum SpeechBackend {
static func module(locale: Locale) async throws -> any SpeechModule {
let choice = Prefs.shared.engineChoice
static func module(locale: Locale, choice: String) async throws -> any SpeechModule {
if choice != "dictation", choice != "legacy",
let supported = await SpeechTranscriber.supportedLocale(equivalentTo: locale) {
return SpeechTranscriber(locale: supported, preset: .progressiveTranscription)
@@ -47,17 +51,29 @@ final class LegacySession: RecognitionSession {
request.addsPunctuation = true
}
static func make(locale: Locale) async throws -> LegacySession {
nonisolated 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)
return await LegacySession(recognizer)
}
func authorize() async throws {
var status = SFSpeechRecognizer.authorizationStatus()
Log.info("legacy speech authorization status=\(status.rawValue)")
if status == .notDetermined {
status = await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
}
}
guard status == .authorized else {
throw NSError(domain: "TinyTTS", code: 4, userInfo: [NSLocalizedDescriptionKey: "请在系统设置 → 隐私与安全性 → 语音识别中允许 TinyTTS。"])
}
}
func start() throws {
guard SFSpeechRecognizer.authorizationStatus() == .authorized else {
SFSpeechRecognizer.requestAuthorization { _ in }
throw NSError(domain: "TinyTTS", code: 4, userInfo: [NSLocalizedDescriptionKey: "旧版引擎需要语音识别权限;请授权后再次开始。"])
}
guard recognizer.isAvailable else {
@@ -71,7 +87,7 @@ final class LegacySession: RecognitionSession {
self.onTextChanged?(self.text)
if result.isFinal { self.done = true }
}
if let error, !self.done { self.done = true; self.onFailure?(error.localizedDescription) }
if let error, !self.done { self.done = true; Log.error("legacy recognition failed: \(error.localizedDescription)"); self.onFailure?(error.localizedDescription) }
}
}
let input = engine.inputNode
@@ -95,7 +111,7 @@ final class LegacySession: RecognitionSession {
func finish() async -> String {
stopAudio()
request.endAudio()
let deadline = Date().addingTimeInterval(2)
let deadline = Date().addingTimeInterval(5)
while !done, Date() < deadline { try? await Task.sleep(for: .milliseconds(20)) }
recognition?.cancel()
return text
+30 -4
View File
@@ -7,11 +7,16 @@ enum SpeechEngine {
private static var warming: Task<Void, Never>?
private static var expiry: Task<Void, Never>?
private static var generation = UUID()
private static var preferLegacy = false
static var statusDescription = "引擎未就绪" {
didSet { NotificationCenter.default.post(name: .init("tinytss.engineStatus"), object: nil) }
}
static var currentLocale: Locale { Locale(identifier: Prefs.shared.languageCode) }
static func warmUp() {
guard warming == nil, warm == nil else { return }
statusDescription = "正在准备本地引擎…"
let token = generation
let locale = currentLocale
warming = Task {
@@ -19,12 +24,14 @@ enum SpeechEngine {
guard token == generation else { await session?.cancel(); return }
warming = nil
warm = session
statusDescription = session == nil ? "本地引擎不可用" : (session is LegacySession ? "备用本地引擎就绪" : "本地引擎就绪")
expiry?.cancel()
expiry = Task {
try? await Task.sleep(for: .seconds(600))
guard !Task.isCancelled, token == generation else { return }
let old = warm
warm = nil
statusDescription = "空闲,已释放预热模型"
await old?.cancel()
}
}
@@ -38,19 +45,38 @@ enum SpeechEngine {
}
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) }
try Task.checkCancellation()
if Prefs.shared.engineChoice == "legacy" || (Prefs.shared.engineChoice == "auto" && preferLegacy) { return try await LegacySession.make(locale: locale) }
do { return try await makeAnalyzer(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 }
try Task.checkCancellation()
Log.error("primary analyzer unavailable: \(error.localizedDescription); trying local dictation")
if let session = try? await makeAnalyzer(locale: locale, forceDictation: true) { return session }
try Task.checkCancellation()
preferLegacy = true
Log.info("using legacy on-device engine after analyzer failures")
return try await LegacySession.make(locale: locale)
}
}
private static func makeAnalyzer(locale: Locale, forceDictation: Bool = false) async throws -> SpeechSession {
let choice = Prefs.shared.engineChoice
let result: Result<SpeechSession, Error>? = await withTimeout(4) {
do { return .success(try await SpeechSession.make(locale: locale, forceDictation: forceDictation, choice: choice)) }
catch { return .failure(error) }
}
guard let result else {
throw NSError(domain: "TinyTTS", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时。"])
}
return try result.get()
}
static func replenish() { warmUp() }
static func resetWarm() {
preferLegacy = false
statusDescription = "引擎未就绪"
generation = UUID()
warming?.cancel()
warming = nil
+17 -4
View File
@@ -38,26 +38,39 @@ final class SpeechSession: RecognitionSession {
self.continuation = continuation
}
static func make(locale: Locale, forceDictation: Bool = false) async throws -> SpeechSession {
nonisolated static func make(locale: Locale, forceDictation: Bool = false, choice: String) async throws -> SpeechSession {
let transcriber: any SpeechModule
if forceDictation {
transcriber = DictationTranscriber(locale: locale, preset: .progressiveShortDictation)
} else {
transcriber = try await SpeechBackend.module(locale: locale)
transcriber = try await SpeechBackend.module(locale: locale, choice: choice)
}
let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber])
?? AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)!
try Task.checkCancellation()
let analyzer = SpeechAnalyzer(modules: [transcriber])
try await analyzer.prepareToAnalyze(in: format)
let prepared: Result<Void, Error>? = await withTimeout(4) {
do { try await analyzer.prepareToAnalyze(in: format); return .success(()) }
catch { return .failure(error) }
}
guard let prepared else {
Task { await analyzer.cancelAndFinishNow() }
throw NSError(domain: "TinyTTS", code: 7, userInfo: [NSLocalizedDescriptionKey: "本地分析器加载超时,正在尝试备用引擎。"])
}
try prepared.get()
if Task.isCancelled {
Task { await analyzer.cancelAndFinishNow() }
throw CancellationError()
}
var captured: AsyncStream<AnalyzerInput>.Continuation!
let stream = AsyncStream<AnalyzerInput>(bufferingPolicy: .unbounded) { cont in
captured = cont
}
return SpeechSession(analyzer: analyzer,
return await SpeechSession(analyzer: analyzer,
transcriber: transcriber,
format: format,
stream: stream,
+7 -4
View File
@@ -4,6 +4,7 @@ import SwiftUI
@MainActor
final class HUDModel: ObservableObject {
enum Phase {
case preparing
case listening
case processing
}
@@ -24,7 +25,7 @@ struct HUDView: View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
if model.phase == .processing {
if model.phase != .listening {
ProgressView()
.controlSize(.small)
.scaleEffect(0.7)
@@ -36,7 +37,7 @@ struct HUDView: View {
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Text(model.phase == .processing ? "正在整理文字…" : model.hint)
Text(model.phase == .preparing ? "等待本地引擎就绪后再说话 · Esc 取消" : (model.phase == .processing ? "正在整理文字…" : model.hint))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
@@ -57,7 +58,7 @@ struct HUDView: View {
private var displayText: String {
if model.text.isEmpty {
return model.phase == .processing ? " " : "聆听中…"
return model.phase == .preparing ? "正在准备本地识别…" : (model.phase == .processing ? " " : "聆听中…")
}
return model.text
}
@@ -99,7 +100,7 @@ final class HUDController {
func show() {
model.text = ""
model.level = 0
model.phase = .listening
model.phase = .preparing
model.hint = "\(Prefs.shared.languageCode) · " + (Prefs.shared.triggerMode == .hold ? "松开结束 · Esc 取消" : "再按一次结束 · Esc 取消")
let panel = ensurePanel()
@@ -128,6 +129,8 @@ final class HUDController {
model.level = model.level * 0.6 + level * 0.4
}
func setListening() { model.phase = .listening }
func setProcessing() {
model.phase = .processing
}
+28 -1
View File
@@ -160,6 +160,9 @@ struct SpeechPane: View {
@ObservedObject var prefs = Prefs.shared
@ObservedObject var models = ModelManager.shared
@ObservedObject var dictation = DictationController.shared
@State private var engineStatus = SpeechEngine.statusDescription
@State private var checkingEngine = false
@State private var diagnosticResult = ""
var body: some View {
Form {
@@ -182,6 +185,7 @@ struct SpeechPane: View {
Text(models.statusText)
.foregroundStyle(models.state.isReady ? Color.secondary : Color.orange)
}
LabeledContent("引擎状态", value: engineStatus)
if case .downloading(let progress) = models.state {
ProgressView(value: progress)
}
@@ -202,7 +206,7 @@ struct SpeechPane: View {
Section {
HStack(spacing: 10) {
Button(dictation.state == .recording ? "结束测试" : "开始测试") {
Button(dictation.state == .starting ? "准备中…" : (dictation.state == .recording ? "结束测试" : "开始测试")) {
if dictation.state == .recording {
dictation.stop()
} else {
@@ -231,6 +235,24 @@ struct SpeechPane: View {
Text("测试语音")
}
Section {
Button(checkingEngine ? "正在检查…" : "检查本地识别(合成语音)") {
checkingEngine = true
diagnosticResult = ""
Task {
do {
try await SelfTest.performLegacy()
diagnosticResult = "本地识别通过,若麦克风测试无文字,请检查输入设备。"
} catch {
diagnosticResult = error.localizedDescription
}
checkingEngine = false
}
}
.disabled(checkingEngine || dictation.state.isBusy)
if !diagnosticResult.isEmpty { Text(diagnosticResult).font(.caption).textSelection(.enabled) }
} header: { Text("诊断") }
Section {
Toggle("自动补句号", isOn: $prefs.autoPeriod)
Text("识别结果保留系统自带标点,只在你停顿时没有句末标点的情况下补一个句号。")
@@ -241,6 +263,9 @@ struct SpeechPane: View {
}
}
.formStyle(.grouped)
.onReceive(NotificationCenter.default.publisher(for: .init("tinytss.engineStatus"))) { _ in
engineStatus = SpeechEngine.statusDescription
}
.onChange(of: dictation.state) { _, newValue in
if newValue == .idle {
dictation.testMode = false
@@ -254,6 +279,8 @@ struct SpeechPane: View {
}
private var testText: String {
if dictation.state == .starting { return "正在准备本地识别,请稍候…" }
if dictation.state == .finalizing { return dictation.liveText.isEmpty ? "正在整理文字…" : dictation.liveText }
if dictation.state == .recording {
return dictation.liveText.isEmpty ? "正在听…" : dictation.liveText
}
@@ -7,6 +7,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
private let menu = NSMenu()
private let stateItem = NSMenuItem()
private let modelItem = NSMenuItem()
private let engineItem = NSMenuItem()
private let permissionItem = NSMenuItem()
private let toggleItem = NSMenuItem()
private let hintItem = NSMenuItem()
@@ -24,7 +25,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
super.init()
menu.delegate = self
menu.autoenablesItems = false
for item in [stateItem, modelItem, permissionItem] { item.isEnabled = false; menu.addItem(item) }
for item in [stateItem, modelItem, engineItem, permissionItem] { item.isEnabled = false; menu.addItem(item) }
menu.addItem(.separator())
toggleItem.target = self
toggleItem.action = #selector(toggleTapped)
@@ -52,6 +53,9 @@ final class StatusItemController: NSObject, NSMenuDelegate {
Prefs.shared.objectWillChange.sink { [weak self] _ in
DispatchQueue.main.async { self?.refresh() }
}.store(in: &subscriptions)
NotificationCenter.default.publisher(for: .init("tinytss.engineStatus")).sink { [weak self] _ in
self?.refresh()
}.store(in: &subscriptions)
update(state: .idle)
}
@@ -82,6 +86,7 @@ final class StatusItemController: NSObject, NSMenuDelegate {
case .finalizing: stateItem.title = "TinyTTS · 正在整理文字…"
}
modelItem.title = "\(prefs.languageCode) · \(ModelManager.shared.statusText)"
engineItem.title = SpeechEngine.statusDescription
permissionItem.title = "麦克风\(Permissions.microphone == .granted ? " ✓" : " 未授权") · 辅助功能\(Permissions.accessibility ? " ✓" : " 未授权")"
toggleItem.title = state == .recording ? "结束并写入" : "开始听写"
toggleItem.isEnabled = state == .idle || state == .recording
+80
View File
@@ -0,0 +1,80 @@
# 验收记录
日期:2026-10-23。此记录区分代码回归、真实系统测试与尚未通过的验收。
## 已通过
- SwiftPM debug 编译及 Swift Testing:14 项测试通过,0 失败。
- Release `.app` 构建,真实 Apple Development 身份签名,沙箱外 `codesign --verify --strict` 通过。
- Info.plist 格式、构建/签名/测试脚本语法检查通过。
- 图标脚本生成 10 个尺寸 PNG、1024 px logo 和 ICNS;大图人工查看,16/32 px 简化细节。图标已包含在 app 中。
- 通过 macOS 原生界面检查通用、快捷键、语音和关于页面。右 Option、按一次开始再按一次结束、固定普通话、自动补句号均与用户确认一致;麦克风与辅助功能显示已授权。
- Release app 约 1.3 MiB(不含系统下载模型),满足 <10 MB 包体目标。
## 自动回归范围
1. 标点保留、补句号、引号、内部空格、口头语与空白输入。
2. 热键 JSON 往返和左右键命名。
3. 静音/有声音量估计与 48 kHz → 16 kHz 重采样。
4. 超时不会等待不响应取消的操作结束。
5. 准备中结束并立即重开:旧会话不会接管新会话。
6. 收尾中取消:不发布结果,不写入。
7. 开始录音失败:清理会话与测试状态。
8. 正常测试模式结束:保留结果,退出测试模式,不写入。
9. 拒绝麦克风:不创建识别会话。
10. 识别运行时失败:取消录音并反馈错误。
11. Secure Input:不开始采集。
12. 右 Option 使用事件自带左右键标志,左键保持按下时仍正确检测右键释放。
13. 等待旧版语音授权时取消,不启动麦克风。
14. 测试模式返回空文字时明确提示失败。
其中状态机回归使用可控会话替身,不代表真实麦克风和跨应用兼容性。
## 真实模型测试:未通过
分别运行已签名 app 的 `--selftest` 与 `--selftest --dictation`。两者均确认中文系统模型为 `installed`;备用测试已完成系统合成语音与文件读取。两条路径都在 SpeechAnalyzer 准备阶段被系统语音服务拒绝:
```
Espresso V2 failure: ANE model load failure:
loadModel:sandboxExtension:options:qos:withReply:: timed out
```
错误来自 Apple EmbeddedAcousticRecognition / localspeechrecognition 服务,已在沙箱外重现。自检返回非零退出码,不会把空结果或关键词不匹配算作成功。自检有 90 秒总超时保护。
因此没有可报告的成功识别延迟,首字 <300 ms、结束到上屏 <500 ms 均未验收。自动模式包含 SpeechTranscriber → DictationTranscriber → 强制本地 SFSpeechRecognizer 降级;旧版真实麦克风路径未验收,不保证能绕过当前系统服务错误。
## 资源与待验收项
一次已运行、打开过四页 SwiftUI 设置的进程快照为 CPU 0.0%、RSS 97,840 KiB(约 95.5 MiB)。这不是长期采样,且未达到 <80 MB 内存目标。后续已加入关闭设置即释放界面、录音时才创建 AVAudioEngine、预热 10 分钟后释放;该优化后尚未获得可比的完整资源验收。
下列项目仍需在本地语音服务正常的机器上完成,不能凭单元测试推定通过:
- 真人麦克风:右 Option 开始/结束、Esc 取消、12 秒无声结束。
- TextEdit、Chrome 输入框、终端分别测试插入点、选区替换、剪贴板恢复与失败恢复。终端使用无副作用的测试文本,不按回车执行。
- AX 回读不一致时不会重复粘贴;前台应用改变或安全输入阻止写入;取消后旧会话不写入。
- 模型首次下载、断网下载失败、重试;全本地降级的真实识别。
- 多屏、Space、全屏窗口的 HUD 定位与不抢焦点。
- 菜单栏录音/收尾动画、设置改绑后真实按键、重启后的偏好保存。
- 登录启动与系统批准;长时间待机 CPU、预热过期后内存;实际真人语音延迟。
## 复现
```bash
./scripts/test.sh
./scripts/build.sh
codesign --verify --strict dist/TinyTTS.app
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest
./dist/TinyTTS.app/Contents/MacOS/TinyTTS --selftest --dictation
```
受限执行环境中 SwiftPM 缓存目录会有不可写提示;脚本已将模块缓存放在仓库 `.build`。macOS 图像服务、dSYM 和签名信任校验可能需要在普通本机终端执行。沙箱内 `CSSMERR_TP_NOT_TRUSTED` 已用沙箱外严格签名校验排除,未更改系统信任设置。
## 本次续作验证
- 重新运行 `./scripts/test.sh`:14 项通过,0 失败。
- 重新构建 0.1.2,使用现有 Apple Development 固定身份签名,严格签名校验通过。
- 现有日志确认修复后右 Option 事件已触发,备用引擎曾在约 126–198 ms 内开始采集,收到非静音音频,但最终返回 0 字。该记录不能当作成功识别或写入验收。
- 普通本机环境运行 `--selftest --legacy`:授权状态为 `notDetermined`,明确失败退出。先前 GUI 进程显示已授权与命令行自检结果不同,因此命令行结果不能证明 GUI 权限被撤销,也不能验证旧版识别成功。
- 测试模式现在会对空结果显示错误;取消权限等待后不再继续获取识别会话。
本次重新运行 `--selftest` 仍以非零状态失败,原始错误为 `Espresso V2 failure: ANE model load failure: loadModel:sandboxExtension:options:qos:withReply:: timed out`,发生在已安装模型的准备阶段;未采集麦克风。
+72 -1
View File
@@ -1,6 +1,7 @@
import Testing
import Foundation
import AVFoundation
import CoreGraphics
@testable import TinyTTS
struct TextTests {
@@ -56,12 +57,19 @@ private final class FakeSession: RecognitionSession {
var cancelled = false
var finishDelay = false
var failStart = false
var authorizationDelay = false
var started = false
var result = "测试"
func authorize() async throws {
if authorizationDelay { try? await Task.sleep(for: .milliseconds(100)) }
}
func start() throws {
started = true
if failStart { throw NSError(domain: "test", code: 1) }
}
func finish() async -> String {
if finishDelay { try? await Task.sleep(for: .milliseconds(80)) }
return "测试"
return result
}
func cancel() async { cancelled = true }
}
@@ -139,6 +147,39 @@ struct SessionTests {
#expect(!controller.testMode)
}
@Test func emptyTestResultReportsFailure() async {
let fake = FakeSession()
fake.result = ""
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
var error: String?
controller.onError = { error = $0 }
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.isEmpty)
#expect(error?.contains("测试未识别到文字") == true)
}
@Test func cancellationWhileAuthorizingNeverStartsMicrophone() async {
let fake = FakeSession()
fake.authorizationDelay = true
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
controller.testMode = true
controller.start()
await settle()
#expect(controller.state == .starting)
#expect(!fake.started)
controller.cancel()
try? await Task.sleep(for: .milliseconds(130))
#expect(controller.state == .idle)
#expect(!fake.started)
#expect(fake.cancelled)
}
@Test func microphoneDenialDoesNotAcquireSession() async {
var acquired = false
let controller = DictationController(acquire: { acquired = true; return FakeSession() }, microphone: { false }, secureInput: { false }, replenish: {})
@@ -174,3 +215,33 @@ struct SessionTests {
#expect(!acquired)
}
}
@MainActor
struct HotkeyEventTests {
@Test func rightOptionUsesEventSnapshotAndReleasesWithLeftHeld() async throws {
let monitor = HotkeyMonitor(spec: .rightOption, secureInput: { false })
var pressed = 0
var released = 0
monitor.handler = { event in
switch event {
case .pressed: pressed += 1
case .released: released += 1
default: break
}
}
let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 61, keyDown: true))
event.flags = CGEventFlags(rawValue: CGEventFlags.maskAlternate.rawValue | 0x40)
monitor.handle(type: .flagsChanged, event: event)
monitor.handle(type: .flagsChanged, event: event)
try? await Task.sleep(for: .milliseconds(20))
#expect(pressed == 1)
event.flags = CGEventFlags(rawValue: CGEventFlags.maskAlternate.rawValue | 0x20)
monitor.handle(type: .flagsChanged, event: event)
try? await Task.sleep(for: .milliseconds(20))
#expect(released == 1)
event.flags = []
monitor.handle(type: .flagsChanged, event: event)
try? await Task.sleep(for: .milliseconds(20))
#expect(released == 1)
}
}