diff --git a/README.md b/README.md index 231a680..ba2c166 100644 --- a/README.md +++ b/README.md @@ -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 ``` 自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。真实识别与跨应用上屏仍需在系统语音服务正常时完成验收。 diff --git a/Resources/Info.plist b/Resources/Info.plist index 8149ebc..104704e 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + 0.1.2 CFBundleVersion - 1 + 3 LSMinimumSystemVersion 26.0 LSUIElement diff --git a/Sources/TinyTTS/App/AppDelegate.swift b/Sources/TinyTTS/App/AppDelegate.swift index 0d7b709..07620f5 100644 --- a/Sources/TinyTTS/App/AppDelegate.swift +++ b/Sources/TinyTTS/App/AppDelegate.swift @@ -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 diff --git a/Sources/TinyTTS/App/DictationController.swift b/Sources/TinyTTS/App/DictationController.swift index d1661bc..a76e778 100644 --- a/Sources/TinyTTS/App/DictationController.swift +++ b/Sources/TinyTTS/App/DictationController.swift @@ -50,6 +50,7 @@ final class DictationController: ObservableObject { private var targetPID: pid_t? private var silenceTask: Task? 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 diff --git a/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift b/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift index f49eb09..ebd2e63 100644 --- a/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift +++ b/Sources/TinyTTS/Hotkey/HotkeyMonitor.swift @@ -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( diff --git a/Sources/TinyTTS/Hotkey/HotkeySpec.swift b/Sources/TinyTTS/Hotkey/HotkeySpec.swift index 9e709cd..e5b1df5 100644 --- a/Sources/TinyTTS/Hotkey/HotkeySpec.swift +++ b/Sources/TinyTTS/Hotkey/HotkeySpec.swift @@ -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 diff --git a/Sources/TinyTTS/SelfTest.swift b/Sources/TinyTTS/SelfTest.swift index dfc4bd1..76a297e 100644 --- a/Sources/TinyTTS/SelfTest.swift +++ b/Sources/TinyTTS/SelfTest.swift @@ -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 ?? "")") + 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? + init(_ continuation: CheckedContinuation) { self.continuation = continuation } + func finish(_ text: String) { + lock.lock() + let current = continuation + continuation = nil + lock.unlock() + current?.resume(returning: text) + } } diff --git a/Sources/TinyTTS/Speech/ModelManager.swift b/Sources/TinyTTS/Speech/ModelManager.swift index a4a1962..60d3653 100644 --- a/Sources/TinyTTS/Speech/ModelManager.swift +++ b/Sources/TinyTTS/Speech/ModelManager.swift @@ -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: "当前系统不支持该语言" diff --git a/Sources/TinyTTS/Speech/SpeechBackend.swift b/Sources/TinyTTS/Speech/SpeechBackend.swift index 427e81b..b4fed94 100644 --- a/Sources/TinyTTS/Speech/SpeechBackend.swift +++ b/Sources/TinyTTS/Speech/SpeechBackend.swift @@ -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 diff --git a/Sources/TinyTTS/Speech/SpeechEngine.swift b/Sources/TinyTTS/Speech/SpeechEngine.swift index cc2ba56..b29839e 100644 --- a/Sources/TinyTTS/Speech/SpeechEngine.swift +++ b/Sources/TinyTTS/Speech/SpeechEngine.swift @@ -7,11 +7,16 @@ enum SpeechEngine { private static var warming: Task? private static var expiry: Task? 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? = 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 diff --git a/Sources/TinyTTS/Speech/SpeechSession.swift b/Sources/TinyTTS/Speech/SpeechSession.swift index 20d0a2e..f634fa4 100644 --- a/Sources/TinyTTS/Speech/SpeechSession.swift +++ b/Sources/TinyTTS/Speech/SpeechSession.swift @@ -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? = 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.Continuation! let stream = AsyncStream(bufferingPolicy: .unbounded) { cont in captured = cont } - return SpeechSession(analyzer: analyzer, + return await SpeechSession(analyzer: analyzer, transcriber: transcriber, format: format, stream: stream, diff --git a/Sources/TinyTTS/UI/HUDController.swift b/Sources/TinyTTS/UI/HUDController.swift index 2c84b94..a24a96f 100644 --- a/Sources/TinyTTS/UI/HUDController.swift +++ b/Sources/TinyTTS/UI/HUDController.swift @@ -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 } diff --git a/Sources/TinyTTS/UI/SettingsView.swift b/Sources/TinyTTS/UI/SettingsView.swift index 88bf82e..e1fd06c 100644 --- a/Sources/TinyTTS/UI/SettingsView.swift +++ b/Sources/TinyTTS/UI/SettingsView.swift @@ -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 } diff --git a/Sources/TinyTTS/UI/StatusItemController.swift b/Sources/TinyTTS/UI/StatusItemController.swift index 5ccdec5..a9d5aac 100644 --- a/Sources/TinyTTS/UI/StatusItemController.swift +++ b/Sources/TinyTTS/UI/StatusItemController.swift @@ -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 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..f0cd0bd --- /dev/null +++ b/TESTING.md @@ -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`,发生在已安装模型的准备阶段;未采集麦克风。 diff --git a/Tests/TinyTTSTests/CoreTests.swift b/Tests/TinyTTSTests/CoreTests.swift index b572561..9b16078 100644 --- a/Tests/TinyTTSTests/CoreTests.swift +++ b/Tests/TinyTTSTests/CoreTests.swift @@ -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) + } +}