feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2
Adds a bundled local ASR model catalog for the macOS app with one-click Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a shared model storage directory used by MLX Qwen3. Fixes the light-mode sidebar material and makes the menu-bar icon follow the system appearance with a refreshed status mark. Renames the built product to OSGKeyboard.app. Bumps version to 0.5.2 (build 19).
This commit is contained in:
@@ -141,6 +141,7 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
|
||||
func testDarwinNotificationPostsWithoutCrashing() {
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
FlowSessionDarwin.postCommandChanged()
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
|
||||
@@ -183,4 +184,112 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testFlowCommandRoundTrip() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
let utteranceId = UUID()
|
||||
let command = FlowCommand(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: 42,
|
||||
action: .startRecording,
|
||||
localeId: "zh-Hans",
|
||||
createdAt: 123
|
||||
)
|
||||
|
||||
FlowSessionBridge.writeCommand(command, defaults: defaults)
|
||||
|
||||
XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: defaults), command)
|
||||
}
|
||||
|
||||
func testFlowResultRoundTripPreservesUtteranceIdentity() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
let utteranceId = UUID()
|
||||
let result = FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: 43,
|
||||
status: .final,
|
||||
text: "hello",
|
||||
warning: "raw fallback",
|
||||
createdAt: 124
|
||||
)
|
||||
|
||||
FlowSessionBridge.writeResult(result, defaults: defaults)
|
||||
|
||||
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), result)
|
||||
FlowSessionBridge.clearResult(defaults: defaults)
|
||||
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
|
||||
}
|
||||
|
||||
func testFlowAckRoundTrip() {
|
||||
let defaults = makeDefaults()
|
||||
let ack = FlowAck(
|
||||
sessionId: UUID(),
|
||||
utteranceId: UUID(),
|
||||
commandSeq: 44,
|
||||
consumedAt: 125
|
||||
)
|
||||
|
||||
FlowSessionBridge.writeAck(ack, defaults: defaults)
|
||||
|
||||
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
|
||||
}
|
||||
|
||||
func testReadySnapshotDrivesHostReady() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
let now = Date().timeIntervalSince1970
|
||||
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
|
||||
let snapshot = FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: true,
|
||||
reason: .ready,
|
||||
heartbeatAt: now,
|
||||
readyAt: now,
|
||||
audioProofAt: now,
|
||||
engineMode: "local",
|
||||
localeId: "zh-Hans",
|
||||
sessionExpiresAt: now + 60
|
||||
)
|
||||
|
||||
FlowSessionBridge.writeReadySnapshot(snapshot, defaults: defaults)
|
||||
|
||||
XCTAssertEqual(FlowSessionBridge.readySnapshot(defaults: defaults), snapshot)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClearFlowStateRemovesProtocolPayloads() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
let utteranceId = UUID()
|
||||
FlowSessionBridge.writeCommand(
|
||||
FlowCommand(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: 1,
|
||||
action: .startRecording,
|
||||
localeId: "en-US"
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: 1,
|
||||
status: .partial,
|
||||
text: "hello"
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
FlowSessionBridge.clearFlowState(defaults: defaults)
|
||||
|
||||
XCTAssertNil(FlowSessionBridge.latestCommand(defaults: defaults))
|
||||
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
|
||||
XCTAssertNil(FlowSessionBridge.readySnapshot(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// LocalASRBiasAdapterTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class LocalASRBiasAdapterTests: XCTestCase {
|
||||
|
||||
private func makeFixtureLexicon() throws -> BuiltinLexiconIndex {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("osg-phrases-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let url = dir.appendingPathComponent("phrases.tsv")
|
||||
let tsv = """
|
||||
word\tpinyin\tsource\tweight
|
||||
SwiftUI\tswift ui\tcomputer_terms\t5
|
||||
Kubernetes\tku bo ne si\tcomputer_terms\t5
|
||||
一致性\tyi zhi xing\tcomputer_terms\t5
|
||||
"""
|
||||
try tsv.write(to: url, atomically: true, encoding: .utf8)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
return BuiltinLexiconIndex(fixtureURL: url)
|
||||
}
|
||||
|
||||
func testAdaptBuildsPromptBiasForQwen3MLX() throws {
|
||||
let lexicon = try makeFixtureLexicon()
|
||||
var dict = PersonalDictionary.empty
|
||||
_ = dict.upsertManual(term: "Cursor")
|
||||
dict.updateAliases(for: dict.entries[0].id, aliases: ["cursor"])
|
||||
|
||||
let payload = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: dict,
|
||||
locale: Locale(identifier: "zh-CN"),
|
||||
capabilities: .qwen3MLX
|
||||
),
|
||||
lexicon: lexicon
|
||||
)
|
||||
|
||||
XCTAssertNotNil(payload.promptBias)
|
||||
XCTAssertTrue(payload.promptBias?.contains("Cursor") == true)
|
||||
XCTAssertTrue(payload.promptBias?.contains("SwiftUI") == true)
|
||||
XCTAssertEqual(payload.diagnostics.userTermCount, 2) // OSGKeyboard system + Cursor
|
||||
XCTAssertGreaterThan(payload.diagnostics.builtinTermCount, 0)
|
||||
}
|
||||
|
||||
func testAdaptProducesPolishFragmentWithoutUserDuplicates() throws {
|
||||
let lexicon = try makeFixtureLexicon()
|
||||
var dict = PersonalDictionary.empty
|
||||
_ = dict.upsertManual(term: "SwiftUI")
|
||||
|
||||
let payload = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: dict,
|
||||
locale: Locale(identifier: "zh-CN"),
|
||||
capabilities: .qwen3MLX
|
||||
),
|
||||
lexicon: lexicon
|
||||
)
|
||||
|
||||
XCTAssertFalse(payload.polishFragment.contains("SwiftUI"))
|
||||
XCTAssertTrue(payload.polishFragment.contains("Kubernetes"))
|
||||
}
|
||||
|
||||
func testCorrectionPairsFromAliases() {
|
||||
var dict = PersonalDictionary.empty
|
||||
_ = dict.upsertManual(term: "Kubernetes")
|
||||
dict.updateAliases(for: dict.entries[0].id, aliases: ["k8s"])
|
||||
|
||||
let payload = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: dict,
|
||||
locale: Locale(identifier: "zh-CN"),
|
||||
capabilities: .qwen3MLX
|
||||
),
|
||||
lexicon: BuiltinLexiconIndex.shared
|
||||
)
|
||||
|
||||
XCTAssertEqual(payload.correctionPairs.count, 1)
|
||||
XCTAssertEqual(payload.correctionPairs[0].alias, "k8s")
|
||||
XCTAssertEqual(payload.correctionPairs[0].term, "Kubernetes")
|
||||
}
|
||||
|
||||
func testTranscriptCorrectorReplacesASCIIAlias() {
|
||||
let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
|
||||
let result = LocalASRTranscriptCorrector.apply(
|
||||
"部署 k8s 集群",
|
||||
pairs: pairs
|
||||
)
|
||||
XCTAssertEqual(result, "部署 Kubernetes 集群")
|
||||
}
|
||||
|
||||
func testTranscriptCorrectorSkipsPartialASCIIMatch() {
|
||||
let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
|
||||
let result = LocalASRTranscriptCorrector.apply(
|
||||
"xk8s集群",
|
||||
pairs: pairs
|
||||
)
|
||||
XCTAssertEqual(result, "xk8s集群")
|
||||
}
|
||||
|
||||
func testBuiltinLexiconParsesTSV() {
|
||||
let terms = BuiltinLexiconIndex.parseTSV(
|
||||
"word\tpinyin\tsource\tweight\nFoo\tfoo\tcomputer_terms\t5\n"
|
||||
)
|
||||
XCTAssertEqual(terms.count, 1)
|
||||
XCTAssertEqual(terms[0].word, "Foo")
|
||||
XCTAssertEqual(terms[0].weight, 5)
|
||||
}
|
||||
|
||||
func testPolishingServiceMergesDictionarySupplement() {
|
||||
let dict = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual),
|
||||
])
|
||||
let merged = PolishingService.mergedDictionaryBlock(
|
||||
dictionary: dict,
|
||||
supplement: "内置技术词汇参考:SwiftUI"
|
||||
)
|
||||
XCTAssertTrue(merged.contains("Cursor"))
|
||||
XCTAssertTrue(merged.contains("SwiftUI"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// LocalASRModelCatalogTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class LocalASRModelCatalogTests: XCTestCase {
|
||||
|
||||
func testBundledCatalogLoads() throws {
|
||||
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||
XCTAssertEqual(catalog.schemaVersion, 1)
|
||||
XCTAssertFalse(catalog.models.isEmpty)
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
|
||||
}
|
||||
|
||||
func testCapabilitiesForSherpaQwen3() throws {
|
||||
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-0.6b-int8", in: catalog))
|
||||
let caps = LocalASRModelCatalog.capabilities(for: model)
|
||||
XCTAssertEqual(caps.hotwordMode, .recognizerScoped)
|
||||
XCTAssertTrue(model.supportsHotwords)
|
||||
}
|
||||
|
||||
func testManifestRoundTrip() throws {
|
||||
let manifest = LocalASRInstalledManifest(
|
||||
selectedModelId: "sherpa-qwen3-0.6b-int8",
|
||||
installedModelIDs: ["sherpa-qwen3-0.6b-int8"]
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("manifest-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
try encoder.encode(manifest).write(to: url)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let loaded = try decoder.decode(LocalASRInstalledManifest.self, from: Data(contentsOf: url))
|
||||
XCTAssertEqual(loaded.selectedModelId, manifest.selectedModelId)
|
||||
XCTAssertEqual(loaded.installedModelIDs, manifest.installedModelIDs)
|
||||
}
|
||||
|
||||
func testBiasDiagnosticsStoreRoundTrip() {
|
||||
LocalASRBiasDiagnosticsStore.clear()
|
||||
let payload = LocalASRBiasPayload(
|
||||
hardHotwords: ["Cursor"],
|
||||
promptBias: "test",
|
||||
corpusContext: nil,
|
||||
polishFragment: "fragment",
|
||||
correctionPairs: [],
|
||||
diagnostics: LocalASRBiasDiagnostics(userTermCount: 2, builtinTermCount: 3)
|
||||
)
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: payload,
|
||||
modelId: "qwen3-mlx-1.7b",
|
||||
backendLabel: "MLX"
|
||||
)
|
||||
let snapshot = LocalASRBiasDiagnosticsStore.load()
|
||||
XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b")
|
||||
XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
|
||||
XCTAssertEqual(snapshot?.hotwordCount, 1)
|
||||
LocalASRBiasDiagnosticsStore.clear()
|
||||
}
|
||||
|
||||
func testSherpaAdapterProducesHardHotwords() throws {
|
||||
let fixtureURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("phrases-\(UUID().uuidString).tsv")
|
||||
try "word\tpinyin\tsource\tweight\nSwiftUI\tswift ui\tcomputer_terms\t5\n"
|
||||
.write(to: fixtureURL, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: fixtureURL) }
|
||||
|
||||
var dict = PersonalDictionary.empty
|
||||
_ = dict.upsertManual(term: "Kubernetes")
|
||||
|
||||
let payload = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: dict,
|
||||
locale: Locale(identifier: "zh-CN"),
|
||||
capabilities: .sherpaQwen3
|
||||
),
|
||||
lexicon: BuiltinLexiconIndex(fixtureURL: fixtureURL)
|
||||
)
|
||||
XCTAssertFalse(payload.hardHotwords.isEmpty)
|
||||
XCTAssertTrue(payload.hardHotwords.contains("Kubernetes"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user