232 lines
8.7 KiB
Swift
232 lines
8.7 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// 听写状态机:空闲 → 录音 → 收尾 → 写入。
|
|
@MainActor
|
|
final class DictationController: ObservableObject {
|
|
static let shared = DictationController()
|
|
|
|
enum State: Equatable {
|
|
case idle
|
|
case starting
|
|
case recording
|
|
case finalizing
|
|
|
|
var isBusy: Bool { self != .idle }
|
|
}
|
|
|
|
@Published private(set) var state: State = .idle {
|
|
didSet { onStateChange?(state) }
|
|
}
|
|
|
|
@Published private(set) var liveText: String = ""
|
|
@Published private(set) var lastResult: String = ""
|
|
|
|
var onStateChange: ((State) -> Void)?
|
|
var onError: ((String) -> Void)?
|
|
|
|
/// 设置页的「测试语音」:只识别,不写入任何 App
|
|
var testMode = false
|
|
|
|
private let acquire: () async throws -> any RecognitionSession
|
|
private let microphone: () async -> Bool
|
|
private let secureInput: () -> Bool
|
|
private let replenish: @MainActor () -> Void
|
|
|
|
init(acquire: @escaping () async throws -> any RecognitionSession = { try await SpeechEngine.acquire() },
|
|
microphone: @escaping () async -> Bool = { await Permissions.requestMicrophone() },
|
|
secureInput: @escaping () -> Bool = { Inserter.isSecureEventInputEnabled() },
|
|
replenish: @escaping @MainActor () -> Void = { SpeechEngine.replenish() }) {
|
|
self.acquire = acquire
|
|
self.microphone = microphone
|
|
self.secureInput = secureInput
|
|
self.replenish = replenish
|
|
}
|
|
|
|
private let prefs = Prefs.shared
|
|
private var session: (any RecognitionSession)?
|
|
private var startedAt: Date?
|
|
private var generation = UUID()
|
|
private var targetPID: pid_t?
|
|
private var silenceTask: Task<Void, Never>?
|
|
private var lastVoice = Date()
|
|
private var recognitionError: String?
|
|
private var hud: HUDController?
|
|
|
|
func attach(hud: HUDController) {
|
|
self.hud = hud
|
|
}
|
|
|
|
func toggle() {
|
|
switch state {
|
|
case .idle: start()
|
|
case .recording: stop()
|
|
case .starting: cancel()
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func start() {
|
|
guard state == .idle else { return }
|
|
guard !secureInput() else { return }
|
|
let token = UUID()
|
|
generation = token
|
|
targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
state = .starting
|
|
liveText = ""
|
|
recognitionError = nil
|
|
if !testMode, prefs.showHUD {
|
|
hud?.show()
|
|
}
|
|
|
|
let t0 = Date()
|
|
Task { [self] in
|
|
do {
|
|
guard await microphone() else {
|
|
throw NSError(domain: "TinySTT", 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 {
|
|
await session.cancel()
|
|
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
|
|
if firstText, !text.isEmpty {
|
|
firstText = false
|
|
Log.info("first text after \(Int(Date().timeIntervalSince(t0) * 1000))ms")
|
|
}
|
|
guard self?.generation == token else { return }
|
|
self?.liveText = text
|
|
self?.hud?.update(text: text)
|
|
}
|
|
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 else { return }
|
|
self.recognitionError = message
|
|
if self.state == .finalizing { return }
|
|
guard self.state == .recording else { return }
|
|
self.cancel()
|
|
self.onError?("本地识别失败:" + message)
|
|
}
|
|
|
|
try session.start()
|
|
startedAt = Date()
|
|
Log.info("recording started (total \(Int(Date().timeIntervalSince(t0) * 1000))ms)")
|
|
state = .recording
|
|
hud?.setListening()
|
|
lastVoice = Date()
|
|
silenceTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
guard let self, self.generation == token, self.state == .recording else { return }
|
|
if self.prefs.silenceTimeout, Date().timeIntervalSince(self.lastVoice) >= 12 {
|
|
self.stop()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if prefs.playSound, !testMode {
|
|
NSSound(named: "Pop")?.play()
|
|
}
|
|
} catch {
|
|
guard generation == token else { return }
|
|
testMode = false
|
|
Log.error("start failed: \(error.localizedDescription)")
|
|
await session?.cancel()
|
|
session = nil
|
|
state = .idle
|
|
hud?.hide()
|
|
onError?(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
if state == .starting { cancel(); return }
|
|
guard state == .recording, let session else { return }
|
|
silenceTask?.cancel()
|
|
let token = generation
|
|
state = .finalizing
|
|
hud?.setProcessing()
|
|
|
|
let duration = Date().timeIntervalSince(startedAt ?? Date())
|
|
let wasTest = testMode
|
|
testMode = false
|
|
|
|
let t1 = Date()
|
|
Task {
|
|
let raw = await session.finish()
|
|
guard generation == token else { return }
|
|
Log.info("finalized in \(Int(Date().timeIntervalSince(t1) * 1000))ms, chars=\(raw.count)")
|
|
self.session = nil
|
|
self.hud?.hide()
|
|
replenish()
|
|
|
|
var text = TextPostProcessor.finalize(raw, addPeriod: prefs.autoPeriod)
|
|
if duration < 0.35 || text.isEmpty {
|
|
text = ""
|
|
}
|
|
self.lastResult = text
|
|
|
|
if wasTest {
|
|
self.state = .idle
|
|
if text.isEmpty, duration >= 0.35 {
|
|
self.onError?(self.recognitionError.map { "本地识别失败:" + $0 } ?? "测试未识别到文字。请检查输入设备,并运行语音页的本地识别诊断。")
|
|
}
|
|
return
|
|
}
|
|
|
|
if text.isEmpty {
|
|
Log.info("empty result, skipped")
|
|
self.state = .idle
|
|
if duration >= 0.35 {
|
|
self.onError?(self.recognitionError.map { "本地识别失败:" + $0 } ?? "本次没有识别到文字。请检查麦克风输入;可以在设置 → 语音中测试。")
|
|
}
|
|
return
|
|
}
|
|
|
|
let outcome = Inserter.insert(text, method: prefs.insertMethod, expectedPID: targetPID)
|
|
Log.info("release to insertion dispatch \(Int(Date().timeIntervalSince(t1) * 1000))ms")
|
|
if prefs.playSound, outcome.ok { NSSound(named: "Glass")?.play() }
|
|
Log.info("insert via \(outcome.method.rawValue) ok=\(outcome.ok), chars=\(text.count)")
|
|
if !outcome.ok {
|
|
self.onError?("未能自动写入当前输入框,识别结果已保留;可从菜单「复制上次识别结果」找回。")
|
|
}
|
|
self.state = .idle
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
guard state.isBusy else { return }
|
|
Log.info("session cancelled, state=\(state)")
|
|
generation = UUID()
|
|
silenceTask?.cancel()
|
|
testMode = false
|
|
let current = session
|
|
session = nil
|
|
state = .idle
|
|
hud?.hide()
|
|
Task {
|
|
await current?.cancel()
|
|
replenish()
|
|
}
|
|
}
|
|
}
|