fix: stabilize dictation hotkeys and local engine fallback
This commit is contained in:
16 files changed
+379
-43
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
自动回归覆盖文字处理、热键编码、重采样、超时以及启动/收尾取消竞态。模型自检使用实时速率喂入合成语音,输出首字和收尾时间;它不代表真人麦克风、全局热键或跨应用写入验收。真实识别与跨应用上屏仍需在系统语音服务正常时完成验收。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "当前系统不支持该语言"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`,发生在已安装模型的准备阶段;未采集麦克风。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user