200 lines
9.8 KiB
Swift
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)
|
|
}
|
|
}
|