Files

200 lines
9.8 KiB
Swift

import AppKit
import AVFoundation
import Foundation
import Speech
/// Feeds synthesized speech to a real installed local analyzer; does not test microphone or insertion.
enum SelfTest {
@MainActor
static func run() -> Never {
setvbuf(stdout, nil, _IONBF, 0)
NSApplication.shared.setActivationPolicy(.accessory)
Task.detached {
try? await Task.sleep(for: .seconds(90))
print("SELFTEST FAILED: watchdog deadline (90s)")
exit(1)
}
Task { @MainActor in
do {
if CommandLine.arguments.contains("--input") {
try await performInput()
exit(0)
}
let choice = CommandLine.arguments.contains("--legacy") ? "legacy" : (CommandLine.arguments.contains("--dictation") ? "dictation" : "speech")
try await perform(choice: choice)
exit(0)
} catch {
print("SELFTEST FAILED: \(error.localizedDescription)")
exit(1)
}
}
NSApplication.shared.run()
exit(1)
}
private static func failure(_ text: String) -> Error {
NSError(domain: "TinySTT.SelfTest", code: 1, userInfo: [NSLocalizedDescriptionKey: text])
}
/// Exercise real AppKit text input and selection replacement, without speech
/// or clipboard mutation, in a disposable window owned by this process.
@MainActor
private static func performInput() async throws {
let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 500, height: 200),
styleMask: [.titled], backing: .buffered, defer: false)
window.title = "TinySTT 自动输入测试"
let view = NSTextView(frame: window.contentView!.bounds)
window.contentView = view
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(view)
NSApp.activate(ignoringOtherApps: true)
defer { window.orderOut(nil) }
try await Task.sleep(for: .milliseconds(300))
let clipboardVersion = NSPasteboard.general.changeCount
let phrase = String(repeating: "中文🙂English ", count: 5)
let first = Inserter.insert(phrase, method: .auto, expectedPID: getpid())
try await Task.sleep(for: .milliseconds(300))
guard first.ok, view.string == phrase else {
throw failure("Unicode 自动输入失败:期望 \(phrase.utf16.count) 个 UTF-16 单元,收到 \(view.string.utf16.count) 个。")
}
view.string = "前旧后"
view.setSelectedRange(NSRange(location: 1, length: 1))
let second = Inserter.insert("替换🙂", method: .auto, expectedPID: getpid())
try await Task.sleep(for: .milliseconds(300))
guard second.ok, view.string == "前替换🙂后" else { throw failure("自动输入选区替换失败。") }
guard NSPasteboard.general.changeCount == clipboardVersion else { throw failure("自动输入意外改动剪贴板。") }
print("SELFTEST PASSED (real AppKit Unicode input, selection replacement, clipboard unchanged)")
}
@MainActor
static func perform(choice: String) async throws {
if choice == "legacy" { try await performLegacy(); return }
let locale = Locale(identifier: "zh-CN")
let expected = "今天的天气不错,我们下午三点开会。"
let module = try await SpeechBackend.module(locale: locale, choice: choice)
let status = await AssetInventory.status(forModules: [module])
print("Model: \(status)")
guard status == .installed else { throw failure("模型未安装;请先在设置中下载模型。") }
let url = FileManager.default.temporaryDirectory.appendingPathComponent("tinystt-\(UUID()).aiff")
defer { try? FileManager.default.removeItem(at: url) }
print("Synthesizing audio…")
let say = Process()
say.executableURL = URL(fileURLWithPath: "/usr/bin/say")
say.arguments = ["-v", "Tingting", "-o", url.path, expected]
try say.run()
say.waitUntilExit()
guard say.terminationStatus == 0 else { throw failure("合成测试语音失败。") }
print("Reading audio…")
let file = try AVAudioFile(forReading: url)
guard let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [module]),
let converter = AVAudioConverter(from: file.processingFormat, to: format) else {
throw failure("无可用转换格式。")
}
print("Preparing analyzer…")
let analyzer = SpeechAnalyzer(modules: [module])
try await analyzer.prepareToAnalyze(in: format)
print("Feeding audio…")
let pair = AsyncStream<AnalyzerInput>.makeStream()
let start = Date()
var segments: [Int: String] = [:]
var volatile = ""
var first: Date?
let results = Task { @MainActor in
func accept(_ range: CMTimeRange, _ text: String, _ final: Bool) {
if !text.isEmpty, first == nil { first = Date() }
if final { segments[Int(range.start.seconds * 1000)] = text; volatile = "" }
else { volatile = text }
}
if let transcriber = module as? SpeechTranscriber {
for try await result in transcriber.results {
accept(result.range, String(result.text.characters), result.isFinal)
}
} else if let transcriber = module as? DictationTranscriber {
for try await result in transcriber.results {
accept(result.range, String(result.text.characters), result.isFinal)
}
}
}
let analysis = Task { try await analyzer.start(inputSequence: pair.stream) }
while file.framePosition < file.length {
let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 1024)!
try file.read(into: buffer, frameCount: 1024)
if let converted = AudioMath.convert(buffer, using: converter, to: format) {
pair.continuation.yield(AnalyzerInput(buffer: converted))
}
// Real-time feed: no accelerated latency claims.
try await Task.sleep(for: .seconds(Double(buffer.frameLength) / file.processingFormat.sampleRate))
}
pair.continuation.finish()
let release = Date()
let finished: Bool? = await withTimeout(15) {
do {
try await analyzer.finalizeAndFinishThroughEndOfInput()
try await results.value
return true
} catch { print("Analyzer error: \(error)"); return false }
}
await analyzer.cancelAndFinishNow()
analysis.cancel()
results.cancel()
guard finished == true else { throw failure("识别超时或失败。") }
let text = segments.keys.sorted().compactMap { segments[$0] }.joined() + volatile
print("Recognized: \(text)")
print("First text from audio start: \(first.map { Int($0.timeIntervalSince(start) * 1000) } ?? -1)ms")
print("End of audio to final: \(Int(Date().timeIntervalSince(release) * 1000))ms")
guard !text.isEmpty, text.contains("天气"), text.contains("开会") else {
throw failure("识别结果未包含预期关键词。")
}
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)
}
}