Files
tinystt/Sources/TinySTT/App/DictationController.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()
}
}
}