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:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
@@ -0,0 +1,99 @@
// LocalASRBiasPayload.swift
// OSGKeyboard · Shared
//
// Output of `LocalASRBiasAdapter` vocabulary signals for each pipeline layer.
import Foundation
public struct LocalASRCorrectionPair: Sendable, Equatable {
public let alias: String
public let term: String
public init(alias: String, term: String) {
self.alias = alias
self.term = term
}
}
public struct LocalASRBiasDiagnostics: Sendable, Equatable, Codable {
public var userTermCount: Int
public var builtinTermCount: Int
public var truncated: Bool
public var truncationReason: String?
public var selectedSources: [String]
public init(
userTermCount: Int = 0,
builtinTermCount: Int = 0,
truncated: Bool = false,
truncationReason: String? = nil,
selectedSources: [String] = []
) {
self.userTermCount = userTermCount
self.builtinTermCount = builtinTermCount
self.truncated = truncated
self.truncationReason = truncationReason
self.selectedSources = selectedSources
}
}
public struct LocalASRBiasPayload: Sendable, Equatable {
public var hardHotwords: [String]
public var promptBias: String?
public var corpusContext: String?
public var polishFragment: String
public var correctionPairs: [LocalASRCorrectionPair]
public var diagnostics: LocalASRBiasDiagnostics
public static let empty = LocalASRBiasPayload(
hardHotwords: [],
promptBias: nil,
corpusContext: nil,
polishFragment: "",
correctionPairs: [],
diagnostics: LocalASRBiasDiagnostics()
)
public init(
hardHotwords: [String],
promptBias: String?,
corpusContext: String?,
polishFragment: String,
correctionPairs: [LocalASRCorrectionPair],
diagnostics: LocalASRBiasDiagnostics
) {
self.hardHotwords = hardHotwords
self.promptBias = promptBias
self.corpusContext = corpusContext
self.polishFragment = polishFragment
self.correctionPairs = correctionPairs
self.diagnostics = diagnostics
}
}
public struct LocalASRBiasRequest: Sendable {
public var dictionary: PersonalDictionary
public var locale: Locale
public var frontAppBundleId: String?
public var capabilities: LocalASRCapabilities
/// Max builtin `phrases.tsv` terms considered for ASR bias (not polish-only).
public var builtinASRLimit: Int
/// Max builtin terms referenced in the polish supplement block.
public var builtinPolishLimit: Int
public init(
dictionary: PersonalDictionary,
locale: Locale,
frontAppBundleId: String? = nil,
capabilities: LocalASRCapabilities,
builtinASRLimit: Int = 300,
builtinPolishLimit: Int = 40
) {
self.dictionary = dictionary
self.locale = locale
self.frontAppBundleId = frontAppBundleId
self.capabilities = capabilities
self.builtinASRLimit = builtinASRLimit
self.builtinPolishLimit = builtinPolishLimit
}
}
@@ -0,0 +1,82 @@
// LocalASRCapabilities.swift
// OSGKeyboard · Shared
//
// Declares what each on-device ASR backend can accept for vocabulary bias.
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
import Foundation
/// How a backend accepts vocabulary hints (honest matrix not every model
/// supports hard hotwords).
public enum LocalASRHotwordMode: String, Sendable, Codable, Equatable {
case none
case promptOnly
case perRequest
case recognizerScoped
case cloudVocabulary
}
/// Cost of refreshing hotwords on a backend (e.g. Sherpa Qwen3 reloads recognizer).
public enum LocalASRHotwordReloadCost: String, Sendable, Codable, Equatable {
case none
case recognizerReload
case modelReload
}
public struct LocalASRCapabilities: Sendable, Equatable {
public let hotwordMode: LocalASRHotwordMode
public let maxHotwordCount: Int
public let maxPromptCharacters: Int
public let supportsStreaming: Bool
public let hotwordReloadCost: LocalASRHotwordReloadCost
public init(
hotwordMode: LocalASRHotwordMode,
maxHotwordCount: Int,
maxPromptCharacters: Int,
supportsStreaming: Bool,
hotwordReloadCost: LocalASRHotwordReloadCost
) {
self.hotwordMode = hotwordMode
self.maxHotwordCount = maxHotwordCount
self.maxPromptCharacters = maxPromptCharacters
self.supportsStreaming = supportsStreaming
self.hotwordReloadCost = hotwordReloadCost
}
/// Qwen3 MLX via mlx-swift-asr `context` soft prompt on `transcribe`.
public static let qwen3MLX = LocalASRCapabilities(
hotwordMode: .promptOnly,
maxHotwordCount: 0,
maxPromptCharacters: 800,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Apple Speech on macOS no project-controlled hotword API today.
public static let appleSpeech = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
/// Sherpa Qwen3 hard hotwords via `--qwen3-asr-hotwords`.
public static let sherpaQwen3 = LocalASRCapabilities(
hotwordMode: .recognizerScoped,
maxHotwordCount: 100,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .recognizerReload
)
/// Sherpa SenseVoice fast Chinese baseline without hotwords.
public static let sherpaSenseVoice = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
}
@@ -0,0 +1,134 @@
// LocalASRModelCatalog.swift
// OSGKeyboard · Shared
//
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
import Foundation
public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
case mlx
case sherpaQwen3
case sherpaSenseVoice
case appleSpeech
}
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
case manual
case archive
case runtime
}
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
public let type: String
public let priority: Int
public let url: String
}
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
public var convFrontend: String?
public var encoder: String?
public var decoder: String?
public var tokenizer: String?
public var senseVoiceModel: String?
public var tokens: String?
}
public struct LocalASRRuntimeDefinition: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let displayName: String
public let installRelativePath: String
public let binaryCandidates: [String]
public let archiveFileName: String
public let sizeBytes: Int
public let platform: String
public let sources: [LocalASRDownloadSource]
}
public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let displayName: String
public let backend: LocalASRModelBackend
public let sizeBytes: Int
public let recommendedLocales: [String]
public let supportsHotwords: Bool
public let hotwordMode: LocalASRHotwordMode
public let installKind: LocalASRInstallKind
public let installRelativePath: String?
public let archiveBaseName: String?
public let layout: LocalASRModelLayout?
public let requiredRelativeFiles: [String]?
public let runtimePlatform: String?
public let sources: [LocalASRDownloadSource]?
}
public struct LocalASRCatalogDocument: Codable, Sendable, Equatable {
public let schemaVersion: Int
public let defaultModelId: String
public let runtimes: [LocalASRRuntimeDefinition]
public let models: [LocalASRModelDefinition]
}
public enum LocalASRModelCatalog {
public static func loadBundled() throws -> LocalASRCatalogDocument {
let bundle = Bundle(for: LocalASRCatalogBundleToken.self)
guard let url = bundle.url(forResource: "local-asr-catalog", withExtension: "json") else {
throw LocalASRModelCatalogError.missingBundledCatalog
}
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(LocalASRCatalogDocument.self, from: data)
}
public static func model(_ id: String, in catalog: LocalASRCatalogDocument) -> LocalASRModelDefinition? {
catalog.models.first { $0.id == id }
}
public static func capabilities(for model: LocalASRModelDefinition) -> LocalASRCapabilities {
switch model.backend {
case .mlx:
return .qwen3MLX
case .sherpaQwen3:
return .sherpaQwen3
case .sherpaSenseVoice:
return .sherpaSenseVoice
case .appleSpeech:
return .appleSpeech
}
}
#if os(macOS)
public static func runtime(for platform: String, in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
if platform == "macos-arm64" {
return catalog.runtimes.first { $0.platform == "macos-arm64" }
}
if platform == "macos-x64" {
return catalog.runtimes.first { $0.platform == "macos-x64" }
}
return catalog.runtimes.first
}
public static func currentRuntimePlatform() -> String {
#if arch(arm64)
return "macos-arm64"
#else
return "macos-x64"
#endif
}
#endif
}
public enum LocalASRModelCatalogError: Error, LocalizedError {
case missingBundledCatalog
case modelNotFound(String)
public var errorDescription: String? {
switch self {
case .missingBundledCatalog:
return "Missing bundled local ASR catalog."
case .modelNotFound(let id):
return "Local ASR model not found: \(id)"
}
}
}
private final class LocalASRCatalogBundleToken {}
@@ -111,4 +111,29 @@ extension PersonalDictionary {
if hasNonASCII { return "zh" }
return "en"
}
/// Alias canonical term pairs for deterministic post-ASR correction.
/// Sorted longest-alias-first by the caller (`LocalASRTranscriptCorrector`).
public func localCorrectionPairs() -> [LocalASRCorrectionPair] {
var seen = Set<String>()
var pairs: [LocalASRCorrectionPair] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { continue }
for alias in entry.aliases {
let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
guard trimmed.caseInsensitiveCompare(term) != .orderedSame else { continue }
let key = "\(trimmed.lowercased())|\(term.lowercased())"
guard seen.insert(key).inserted else { continue }
pairs.append(LocalASRCorrectionPair(alias: trimmed, term: term))
}
}
return pairs.sorted { lhs, rhs in
if lhs.alias.count != rhs.alias.count {
return lhs.alias.count > rhs.alias.count
}
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
}
}
}
@@ -24,6 +24,10 @@ public struct PolishContext: Sendable {
/// bias terminology choices.
public let precedingText: String?
/// Extra dictionary block appended after `PersonalDictionary.promptFragment()`
/// (e.g. builtin `phrases.tsv` terms on macOS local ASR).
public let dictionarySupplement: String?
/// Cap on how many characters of `precedingText` we actually
/// include in the prompt. The full preceding text is often
/// hundreds of KB in a long note we only need the tail.
@@ -33,11 +37,13 @@ public struct PolishContext: Sendable {
appContext: AppContext = .unknown,
intensity: PolishIntensity = .default,
precedingText: String? = nil,
dictionarySupplement: String? = nil,
maxPrecedingChars: Int = 500
) {
self.appContext = appContext
self.intensity = intensity
self.precedingText = precedingText
self.dictionarySupplement = dictionarySupplement
self.maxPrecedingChars = maxPrecedingChars
}
@@ -74,7 +74,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
configuration.engineMode = engineMode
applyEngineModeSideEffects()
persistConfiguration()
persistConfiguration(postConfigChanged: true)
}
}
@Published public var hasCompletedOnboarding: Bool {
@@ -0,0 +1,102 @@
{
"schemaVersion": 1,
"defaultModelId": "qwen3-mlx-1.7b",
"runtimes": [
{
"id": "sherpa-onnx-1.13.4-macos-arm64",
"displayName": "sherpa-onnx 1.13.4 (Apple Silicon)",
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-arm64",
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
"archiveFileName": "sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2",
"sizeBytes": 120000000,
"platform": "macos-arm64",
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2"
}
]
},
{
"id": "sherpa-onnx-1.13.4-macos-x64",
"displayName": "sherpa-onnx 1.13.4 (Intel)",
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-x64",
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
"archiveFileName": "sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2",
"sizeBytes": 130000000,
"platform": "macos-x64",
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2"
}
]
}
],
"models": [
{
"id": "qwen3-mlx-1.7b",
"displayName": "Qwen3-ASR 1.7B (MLX)",
"backend": "mlx",
"sizeBytes": 1400000000,
"recommendedLocales": ["zh-CN", "en-US"],
"supportsHotwords": true,
"hotwordMode": "promptOnly",
"installKind": "manual",
"installRelativePath": "models/qwen3-asr-1.7b-mlx",
"requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"]
},
{
"id": "sherpa-qwen3-0.6b-int8",
"displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)",
"backend": "sherpaQwen3",
"runtimePlatform": "macos",
"sizeBytes": 650000000,
"recommendedLocales": ["zh-CN", "en-US"],
"supportsHotwords": true,
"hotwordMode": "recognizerScoped",
"installKind": "archive",
"installRelativePath": "models/sherpa-qwen3-0.6b-int8",
"archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
"layout": {
"convFrontend": "conv_frontend.onnx",
"encoder": "encoder.int8.onnx",
"decoder": "decoder.int8.onnx",
"tokenizer": "tokenizer"
},
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2"
}
]
},
{
"id": "sherpa-sensevoice-small-int8",
"displayName": "SenseVoice Small (Sherpa)",
"backend": "sherpaSenseVoice",
"runtimePlatform": "macos",
"sizeBytes": 250000000,
"recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"],
"supportsHotwords": false,
"hotwordMode": "none",
"installKind": "archive",
"installRelativePath": "models/sherpa-sensevoice-small-int8",
"archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17",
"layout": {
"senseVoiceModel": "model.int8.onnx",
"tokens": "tokens.txt"
},
"sources": [
{
"type": "github",
"priority": 1,
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
}
]
}
]
}
@@ -92,6 +92,7 @@ public struct AppGroupStore: @unchecked Sendable {
config.model = openAI.defaultModel
}
}
AppGroupConfigDarwin.postConfigChanged()
}
public func setUILanguage(_ language: AppUILanguage) {
@@ -0,0 +1,152 @@
// BuiltinLexiconIndex.swift
// OSGKeyboard · Shared
//
// In-memory index over bundled `phrases.tsv` (~10k computer terms).
// macOS local ASR consumes a Top-N subset; the full index also backs
// polish supplements and future retrieval.
import Foundation
public final class BuiltinLexiconIndex: @unchecked Sendable {
public struct Term: Sendable, Equatable {
public let word: String
public let pinyin: String
public let source: String
public let weight: Int
}
public static let shared = BuiltinLexiconIndex()
private let lock = NSLock()
private var cachedTerms: [Term]?
private let injectedURL: URL?
/// Production singleton loads from the app bundle.
private init() {
injectedURL = nil
}
/// Test / preview hook with an explicit TSV file or inline fixture.
init(fixtureURL: URL) {
injectedURL = fixtureURL
}
/// Parse TSV content without touching the bundle (unit tests).
public static func parseTSV(_ content: String) -> [Term] {
var terms: [Term] = []
terms.reserveCapacity(256)
for (lineIndex, line) in content.split(whereSeparator: \.isNewline).enumerated() {
if lineIndex == 0, line.hasPrefix("word\t") { continue }
let columns = line.split(separator: "\t", omittingEmptySubsequences: false)
guard columns.count >= 4 else { continue }
let word = String(columns[0]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !word.isEmpty else { continue }
let pinyin = String(columns[1])
let source = String(columns[2])
let weight = Int(columns[3]) ?? 1
terms.append(Term(word: word, pinyin: pinyin, source: source, weight: weight))
}
return terms
}
public func termCount() -> Int {
lock.lock()
defer { lock.unlock() }
return loadTermsLocked().count
}
/// Returns canonical words ranked for ASR bias injection.
public func topTerms(
limit: Int,
minimumWeight: Int = 4,
preferredSources: Set<String>? = nil
) -> [String] {
guard limit > 0 else { return [] }
lock.lock()
let all = loadTermsLocked()
lock.unlock()
let filtered = all.filter { term in
guard term.weight >= minimumWeight else { return false }
if let preferredSources, !preferredSources.isEmpty {
return preferredSources.contains(term.source)
}
return true
}
let ranked = filtered.sorted { lhs, rhs in
let leftScore = Self.rankingScore(lhs)
let rightScore = Self.rankingScore(rhs)
if leftScore != rightScore { return leftScore > rightScore }
return lhs.word.localizedCaseInsensitiveCompare(rhs.word) == .orderedAscending
}
var seen = Set<String>()
var words: [String] = []
words.reserveCapacity(min(limit, ranked.count))
for term in ranked {
let key = term.word.lowercased()
guard seen.insert(key).inserted else { continue }
words.append(term.word)
if words.count >= limit { break }
}
return words
}
// MARK: - Private
private func loadTermsLocked() -> [Term] {
if let cachedTerms { return cachedTerms }
let loaded: [Term]
if let injectedURL {
loaded = Self.load(from: injectedURL)
} else if let url = Self.locateBundledPhrasesURL() {
loaded = Self.load(from: url)
} else {
loaded = []
}
cachedTerms = loaded
return loaded
}
private static func load(from url: URL) -> [Term] {
guard let data = try? Data(contentsOf: url),
let content = String(data: data, encoding: .utf8) else {
return []
}
return parseTSV(content)
}
private static func locateBundledPhrasesURL() -> URL? {
let candidates: [Bundle] = [Bundle.main, Bundle(for: BuiltinLexiconIndex.self)]
for bundle in candidates {
if let url = bundle.url(
forResource: "phrases",
withExtension: "tsv",
subdirectory: "CustomLanguageModel/v1"
) {
return url
}
if let url = bundle.url(forResource: "phrases", withExtension: "tsv") {
return url
}
}
return nil
}
private static func rankingScore(_ term: Term) -> Int {
var score = term.weight * 100
if containsLatinLetters(term.word) { score += 50 }
if term.word.count <= 8 { score += 10 }
return score
}
private static func containsLatinLetters(_ text: String) -> Bool {
text.unicodeScalars.contains { scalar in
scalar.isASCII && CharacterSet.letters.contains(scalar)
}
}
}
@@ -384,19 +384,26 @@ public final class FlowContinuousCapture {
/// Re-activate capture after returning from background without
/// reinstalling the tap (iOS may deactivate the audio session).
public func reassertIfRunning() {
guard isRunning else { return }
@discardableResult
public func reassertIfRunning() -> Bool {
guard isRunning else { return false }
let session = AVAudioSession.sharedInstance()
try? session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try? session.setActive(true, options: .notifyOthersOnDeactivation)
if !audioEngine.isRunning {
try? audioEngine.start()
do {
try session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
if !audioEngine.isRunning {
try audioEngine.start()
}
notifyEngineLiveChanged()
return engineIsLive
} catch {
notifyEngineLiveChanged()
return false
}
notifyEngineLiveChanged()
}
public func awaitAudioFlowing(
@@ -6,6 +6,158 @@
import Foundation
public struct FlowCommand: Codable, Equatable, Sendable {
public enum Action: String, Codable, Sendable {
case startRecording
case stopRecording
case abort
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let action: Action
public let localeId: String
public let createdAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
action: Action,
localeId: String,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.action = action
self.localeId = localeId
self.createdAt = createdAt
}
}
public struct FlowResult: Codable, Equatable, Sendable {
public enum Status: String, Codable, Sendable {
case partial
case final
case error
case aborted
case timeout
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let status: Status
public let text: String?
public let warning: String?
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
public let createdAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
status: Status,
text: String? = nil,
warning: String? = nil,
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.status = status
self.text = text
self.warning = warning
self.errorKind = errorKind
self.createdAt = createdAt
}
}
public struct FlowAck: Codable, Equatable, Sendable {
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let consumedAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
consumedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.consumedAt = consumedAt
}
}
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
public enum Reason: String, Codable, Sendable {
case ready
case noSession
case starting
case audioEngineNotLive
case waitingForAudioProof
case recording
case processing
case permissionMissing
case appGroupUnavailable
case hostLost
case error
}
public let protocolVersion: Int
public let sessionId: UUID?
public let ready: Bool
public let reason: Reason
public let heartbeatAt: TimeInterval
public let readyAt: TimeInterval?
public let audioProofAt: TimeInterval?
public let engineMode: String
public let localeId: String
public let busyUtteranceId: UUID?
public let sessionExpiresAt: TimeInterval?
public init(
protocolVersion: Int = 1,
sessionId: UUID?,
ready: Bool,
reason: Reason,
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
readyAt: TimeInterval? = nil,
audioProofAt: TimeInterval? = nil,
engineMode: String,
localeId: String,
busyUtteranceId: UUID? = nil,
sessionExpiresAt: TimeInterval? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.ready = ready
self.reason = reason
self.heartbeatAt = heartbeatAt
self.readyAt = readyAt
self.audioProofAt = audioProofAt
self.engineMode = engineMode
self.localeId = localeId
self.busyUtteranceId = busyUtteranceId
self.sessionExpiresAt = sessionExpiresAt
}
}
public struct FlowTranscriptionError: Equatable, Sendable {
public let message: String
public let kind: FlowSessionKeys.TranscriptionErrorKind
@@ -36,6 +188,15 @@ public enum FlowSessionBridge {
}
}
private static func encode<T: Encodable>(_ value: T) -> Data? {
try? JSONEncoder().encode(value)
}
private static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
guard let data else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
/// Keyboard/read side: refresh App Group defaults after the extension was
/// suspended so decisions are not based on stale in-process caches.
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
@@ -45,10 +206,87 @@ public enum FlowSessionBridge {
}
}
// MARK: - Typed Flow protocol
public static func writeCommand(_ command: FlowCommand, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(command) {
store.set(data, forKey: FlowSessionKeys.flowCommandPayload)
}
flush(store)
FlowSessionDarwin.postCommandChanged()
}
public static func latestCommand(defaults: UserDefaults? = nil) -> FlowCommand? {
let store = resolvedDefaults(defaults)
return decode(FlowCommand.self, from: store.data(forKey: FlowSessionKeys.flowCommandPayload))
}
public static func writeResult(_ result: FlowResult, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(result) {
store.set(data, forKey: FlowSessionKeys.flowResultPayload)
}
flush(store)
FlowSessionDarwin.postTranscriptionChanged()
}
public static func latestResult(defaults: UserDefaults? = nil) -> FlowResult? {
let store = resolvedDefaults(defaults)
return decode(FlowResult.self, from: store.data(forKey: FlowSessionKeys.flowResultPayload))
}
public static func clearResult(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
flush(store)
}
public static func writeAck(_ ack: FlowAck, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(ack) {
store.set(data, forKey: FlowSessionKeys.flowAckPayload)
}
flush(store)
}
public static func latestAck(defaults: UserDefaults? = nil) -> FlowAck? {
let store = resolvedDefaults(defaults)
return decode(FlowAck.self, from: store.data(forKey: FlowSessionKeys.flowAckPayload))
}
public static func writeReadySnapshot(_ snapshot: FlowReadySnapshot, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if let data = encode(snapshot) {
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
if snapshot.ready {
store.set(true, forKey: FlowSessionKeys.flowHostReady)
if let readyAt = snapshot.readyAt {
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
}
} else {
clearHostReady(defaults: store, notify: false)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
if let expires = snapshot.sessionExpiresAt {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
}
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
flush(store)
FlowSessionDarwin.postHostReadyChanged()
}
public static func readySnapshot(defaults: UserDefaults? = nil) -> FlowReadySnapshot? {
let store = resolvedDefaults(defaults)
return decode(FlowReadySnapshot.self, from: store.data(forKey: FlowSessionKeys.flowReadyPayload))
}
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
@@ -59,8 +297,26 @@ public enum FlowSessionBridge {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
if let sessionId {
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
sessionExpiresAt: expires
)
if let data = encode(snapshot) {
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
} else {
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
flush(store)
}
@@ -69,8 +325,11 @@ public enum FlowSessionBridge {
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
clearHostReady(defaults: store, notify: false)
flush(store)
}
@@ -185,6 +444,15 @@ public enum FlowSessionBridge {
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
if let snapshot = readySnapshot(defaults: store) {
guard snapshot.ready else { return false }
guard isHostReachable(defaults: store) else { return false }
if let readyAt = snapshot.readyAt {
let skew = abs(snapshot.heartbeatAt - readyAt)
guard skew <= FlowSessionKeys.hostReadyMaxHeartbeatSkew else { return false }
}
return true
}
guard isHostReachable(defaults: store) else { return false }
return store.bool(forKey: FlowSessionKeys.flowHostReady)
}
@@ -393,6 +661,10 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
@@ -8,6 +8,8 @@ import Foundation
public enum FlowSessionDarwin {
public static let notificationName = "com.osgkeyboard.flow.session.changed"
/// Posted when the keyboard writes a command for the host app.
public static let commandNotificationName = "com.osgkeyboard.flow.command.changed"
/// Posted when the host app writes a transcription result or error.
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
/// Posted when the host app publishes or clears the ready contract.
@@ -23,6 +25,16 @@ public enum FlowSessionDarwin {
)
}
public static func postCommandChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(commandNotificationName as CFString),
nil,
nil,
true
)
}
public static func postTranscriptionChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
@@ -7,6 +7,10 @@
import Foundation
public enum FlowSessionKeys {
public static let flowCommandPayload = "flow.commandPayload.v1"
public static let flowResultPayload = "flow.resultPayload.v1"
public static let flowAckPayload = "flow.ackPayload.v1"
public static let flowReadyPayload = "flow.readyPayload.v1"
public static let flowSessionActive = "flow.flowSessionActive"
public static let flowSessionExpires = "flow.flowSessionExpires"
public static let flowHeartbeat = "flow.flowHeartbeat"
@@ -74,7 +78,7 @@ public enum FlowSessionKeys {
}
/// Structured host keyboard transcription failure kind.
public enum TranscriptionErrorKind: String, Sendable, Equatable {
public enum TranscriptionErrorKind: String, Sendable, Equatable, Codable {
case noSpeech
case recognitionInterrupted
case audioUnavailable
@@ -0,0 +1,159 @@
// LocalASRBiasAdapter.swift
// OSGKeyboard · Shared
//
// Maps `PersonalDictionary` + builtin lexicon + runtime context into the
// layered bias outputs consumed by local ASR, correction, and polish.
import Foundation
public enum LocalASRBiasAdapter {
/// Bundle IDs where computer-science vocabulary is especially likely.
private static let codeEditorBundleIDs: Set<String> = [
"com.apple.dt.Xcode",
"com.microsoft.VSCode",
"com.google.android.studio",
"com.jetbrains.intellij",
"com.jetbrains.AppCode",
"com.sublimetext.4",
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
]
public static func adapt(
_ request: LocalASRBiasRequest,
lexicon: BuiltinLexiconIndex = .shared
) -> LocalASRBiasPayload {
let capabilities = request.capabilities
let dictionary = request.dictionary
var selectedSources = ["user"]
let preferredSources = Self.preferredLexiconSources(for: request.frontAppBundleId)
if preferredSources != nil {
selectedSources.append("builtin-computer")
} else {
selectedSources.append("builtin-top")
}
let userSorted = dictionary.effectiveEntries.sorted { $0.usageCount > $1.usageCount }
var mergedTerms: [String] = []
var seen = Set<String>()
func appendTerm(_ term: String) {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let key = trimmed.lowercased()
guard seen.insert(key).inserted else { return }
mergedTerms.append(trimmed)
}
for entry in userSorted {
appendTerm(entry.term)
}
let userTermCount = mergedTerms.count
let builtinWords = lexicon.topTerms(
limit: request.builtinASRLimit,
minimumWeight: 4,
preferredSources: preferredSources
)
let beforeBuiltin = mergedTerms.count
for word in builtinWords {
appendTerm(word)
}
let builtinTermCount = mergedTerms.count - beforeBuiltin
var hardHotwords: [String] = []
switch capabilities.hotwordMode {
case .perRequest, .recognizerScoped:
let cap = max(capabilities.maxHotwordCount, 1)
hardHotwords = Self.hardHotwordList(from: mergedTerms, maxCount: cap)
case .cloudVocabulary:
hardHotwords = dictionary.asrHotwords(maxCount: max(capabilities.maxHotwordCount, 1))
case .none, .promptOnly:
break
}
var promptBias: String?
var truncated = false
var truncationReason: String?
if capabilities.hotwordMode == .promptOnly, capabilities.maxPromptCharacters > 0 {
let built = Self.buildPromptBias(
dictionary: dictionary,
builtinTerms: builtinWords,
maxCharacters: capabilities.maxPromptCharacters
)
if built.count > capabilities.maxPromptCharacters {
truncated = true
truncationReason = "promptBias exceeded \(capabilities.maxPromptCharacters) characters"
}
promptBias = built.isEmpty ? nil : built
}
let polishFragment = Self.buildPolishFragment(
dictionary: dictionary,
builtinTerms: builtinWords,
maxTerms: request.builtinPolishLimit
)
let correctionPairs = dictionary.localCorrectionPairs()
return LocalASRBiasPayload(
hardHotwords: hardHotwords,
promptBias: promptBias,
corpusContext: promptBias,
polishFragment: polishFragment,
correctionPairs: correctionPairs,
diagnostics: LocalASRBiasDiagnostics(
userTermCount: userTermCount,
builtinTermCount: builtinTermCount,
truncated: truncated,
truncationReason: truncationReason,
selectedSources: selectedSources
)
)
}
// MARK: - Private
private static func preferredLexiconSources(for bundleId: String?) -> Set<String>? {
guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
return ["computer_terms"]
}
private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
Array(terms.prefix(maxCount))
}
private static func buildPromptBias(
dictionary: PersonalDictionary,
builtinTerms: [String],
maxCharacters: Int
) -> String {
let userBias = dictionary.asrPromptBias(maxCharacters: maxCharacters)
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
let extras = builtinTerms.filter { !userTermsLower.contains($0.lowercased()) }
guard !extras.isEmpty else { return userBias }
let extraBlock = "常见技术词汇:\(extras.prefix(80).joined(separator: ""))"
if userBias.isEmpty {
return String(extraBlock.prefix(maxCharacters))
}
let combined = userBias + "" + extraBlock
return String(combined.prefix(maxCharacters))
}
private static func buildPolishFragment(
dictionary: PersonalDictionary,
builtinTerms: [String],
maxTerms: Int
) -> String {
let userTermsLower = Set(dictionary.effectiveEntries.map { $0.term.lowercased() })
let extras = builtinTerms
.filter { !userTermsLower.contains($0.lowercased()) }
.prefix(maxTerms)
guard !extras.isEmpty else { return "" }
return "内置技术词汇参考(需原样保留):\(extras.joined(separator: ""))"
}
}
@@ -0,0 +1,56 @@
// LocalASRBiasDiagnosticsStore.swift
// OSGKeyboard · Shared
//
// Persists the most recent local ASR bias diagnostics for settings / debug UI.
import Foundation
public struct LocalASRBiasDiagnosticsSnapshot: Codable, Sendable, Equatable {
public var capturedAt: Date
public var modelId: String?
public var backendLabel: String?
public var diagnostics: LocalASRBiasDiagnostics
public var hotwordCount: Int
public var promptBiasLength: Int
public init(
capturedAt: Date = Date(),
modelId: String? = nil,
backendLabel: String? = nil,
diagnostics: LocalASRBiasDiagnostics,
hotwordCount: Int = 0,
promptBiasLength: Int = 0
) {
self.capturedAt = capturedAt
self.modelId = modelId
self.backendLabel = backendLabel
self.diagnostics = diagnostics
self.hotwordCount = hotwordCount
self.promptBiasLength = promptBiasLength
}
}
public enum LocalASRBiasDiagnosticsStore {
private static let defaultsKey = "mac.localASR.lastBiasDiagnostics"
public static func save(payload: LocalASRBiasPayload, modelId: String?, backendLabel: String?) {
let snapshot = LocalASRBiasDiagnosticsSnapshot(
modelId: modelId,
backendLabel: backendLabel,
diagnostics: payload.diagnostics,
hotwordCount: payload.hardHotwords.count,
promptBiasLength: payload.promptBias?.count ?? 0
)
guard let data = try? JSONEncoder().encode(snapshot) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
public static func load() -> LocalASRBiasDiagnosticsSnapshot? {
guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return nil }
return try? JSONDecoder().decode(LocalASRBiasDiagnosticsSnapshot.self, from: data)
}
public static func clear() {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
}
@@ -0,0 +1,32 @@
// LocalASRInstalledManifestIO.swift
// OSGKeyboard · Shared
import Foundation
public enum LocalASRInstalledManifestIO {
public static func manifestURL(fileManager: FileManager = .default) -> URL {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport
.appendingPathComponent("OSGKeyboard/LocalASRModels/installed-manifest.json")
}
public static func load(defaultModelId: String, fileManager: FileManager = .default) -> LocalASRInstalledManifest {
let url = manifestURL(fileManager: fileManager)
guard let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(LocalASRInstalledManifest.self, from: data) else {
return LocalASRInstalledManifest(selectedModelId: defaultModelId)
}
return manifest
}
public static func save(_ manifest: LocalASRInstalledManifest, fileManager: FileManager = .default) throws {
let url = manifestURL(fileManager: fileManager)
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(manifest)
try data.write(to: url, options: .atomic)
}
}
@@ -0,0 +1,157 @@
// LocalASRModelDownloadClient.swift
// OSGKeyboard · Shared
//
// URLSession download with byte-level progress and pause/resume (macOS local model installs).
import Foundation
#if os(macOS)
public struct LocalASRDownloadProgressUpdate: Sendable {
public let bytesReceived: Int64
public let bytesTotal: Int64
public var fraction: Double {
guard bytesTotal > 0 else { return 0 }
return min(1, max(0, Double(bytesReceived) / Double(bytesTotal)))
}
}
/// Controls an in-flight URLSession download; supports pause via resume data.
public final class LocalASRModelDownloadController: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
private let destinationURL: URL
private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void
private lazy var delegateSession: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
private var remoteURL: URL?
private var task: URLSessionDownloadTask?
private var completionContinuation: CheckedContinuation<Void, Error>?
private var isPausing = false
private var finished = false
init(
destinationURL: URL,
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
) {
self.destinationURL = destinationURL
self.onProgress = onProgress
super.init()
}
/// Runs until the archive is fully written to `destinationURL` (survives pause/resume).
public func download(from remoteURL: URL) async throws {
self.remoteURL = remoteURL
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
completionContinuation = continuation
startTask(resumeData: nil)
}
}
public func pause() async throws -> Data {
guard task != nil, !finished else {
throw LocalASRModelManagerError.downloadFailed("No active download to pause.")
}
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
isPausing = true
task?.cancel(byProducingResumeData: { [weak self] data in
guard let self else { return }
self.isPausing = false
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: LocalASRModelManagerError.downloadFailed("Pause failed."))
}
})
}
}
/// Continues a paused download; `download(from:)` must still be awaiting.
public func resumeFromPause(_ resumeData: Data) {
finished = false
startTask(resumeData: resumeData)
}
public func cancel() {
finished = true
task?.cancel()
completionContinuation?.resume(throwing: CancellationError())
completionContinuation = nil
delegateSession.invalidateAndCancel()
}
private func startTask(resumeData: Data?) {
if let resumeData {
task = delegateSession.downloadTask(withResumeData: resumeData)
} else if let remoteURL {
task = delegateSession.downloadTask(with: remoteURL)
}
task?.resume()
}
// MARK: - URLSessionDownloadDelegate
public func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
onProgress(
LocalASRDownloadProgressUpdate(
bytesReceived: totalBytesWritten,
bytesTotal: max(totalBytesExpectedToWrite, 1)
)
)
}
public func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
guard !finished else { return }
finished = true
do {
let fm = FileManager.default
if fm.fileExists(atPath: destinationURL.path) {
try fm.removeItem(at: destinationURL)
}
try fm.moveItem(at: location, to: destinationURL)
completionContinuation?.resume()
} catch {
completionContinuation?.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
}
completionContinuation = nil
session.finishTasksAndInvalidate()
}
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard !finished else { return }
if isPausing { return }
if let error {
finished = true
completionContinuation?.resume(
throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)
)
completionContinuation = nil
session.finishTasksAndInvalidate()
}
}
}
public enum LocalASRModelDownloadClient {
public static func makeController(
destinationURL: URL,
onProgress: @escaping @Sendable (LocalASRDownloadProgressUpdate) -> Void
) -> LocalASRModelDownloadController {
LocalASRModelDownloadController(destinationURL: destinationURL, onProgress: onProgress)
}
}
#endif
@@ -0,0 +1,138 @@
// LocalASRModelInstallState.swift
// OSGKeyboard · Shared
import Foundation
public enum LocalASRModelInstallState {
public static func rootDirectory(fileManager: FileManager = .default) -> URL {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
}
public static func installDirectory(for relativePath: String, fileManager: FileManager = .default) -> URL {
rootDirectory(fileManager: fileManager).appendingPathComponent(relativePath, isDirectory: true)
}
public static func isInstalled(
_ model: LocalASRModelDefinition,
manualMLXPath: String?,
fileManager: FileManager = .default
) -> Bool {
switch model.installKind {
case .manual:
guard let required = model.requiredRelativeFiles, !required.isEmpty else { return false }
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
guard fileManager.fileExists(atPath: base.path) else { return false }
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
case .archive:
guard let relative = model.installRelativePath,
let layout = model.layout,
let baseName = model.archiveBaseName else { return false }
let root = installDirectory(for: relative, fileManager: fileManager)
.appendingPathComponent(baseName, isDirectory: true)
return validateArchiveModel(at: root, model: model, layout: layout, fileManager: fileManager)
case .runtime:
return false
}
}
public static func modelRootURL(
_ model: LocalASRModelDefinition,
fileManager: FileManager = .default
) -> URL? {
guard model.installKind == .archive,
let relative = model.installRelativePath,
let baseName = model.archiveBaseName else { return nil }
return installDirectory(for: relative, fileManager: fileManager)
.appendingPathComponent(baseName, isDirectory: true)
}
public static func resolveRuntimeBinary(
runtime: LocalASRRuntimeDefinition,
fileManager: FileManager = .default
) -> URL? {
let root = installDirectory(for: runtime.installRelativePath, fileManager: fileManager)
for candidate in runtime.binaryCandidates {
let direct = root.appendingPathComponent(candidate)
if fileManager.isExecutableFile(atPath: direct.path) {
return direct
}
}
for candidate in runtime.binaryCandidates {
let name = (candidate as NSString).lastPathComponent
if let found = findExecutable(named: name, under: root, fileManager: fileManager) {
return found
}
}
return nil
}
public static func isRuntimeInstalled(
_ runtime: LocalASRRuntimeDefinition,
fileManager: FileManager = .default
) -> Bool {
resolveRuntimeBinary(runtime: runtime, fileManager: fileManager) != nil
}
// MARK: - Private
private static func validateArchiveModel(
at root: URL,
model: LocalASRModelDefinition,
layout: LocalASRModelLayout,
fileManager: FileManager
) -> Bool {
switch model.backend {
case .sherpaQwen3:
guard let conv = layout.convFrontend,
let encoder = layout.encoder,
let decoder = layout.decoder,
let tokenizer = layout.tokenizer else { return false }
return fileManager.fileExists(atPath: root.appendingPathComponent(conv).path)
&& fileManager.fileExists(atPath: root.appendingPathComponent(encoder).path)
&& fileManager.fileExists(atPath: root.appendingPathComponent(decoder).path)
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokenizer, isDirectory: true).path)
case .sherpaSenseVoice:
guard let onnx = layout.senseVoiceModel,
let tokens = layout.tokens else { return false }
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
default:
return false
}
}
private static func findExecutable(
named name: String,
under root: URL,
fileManager: FileManager
) -> URL? {
guard let enumerator = fileManager.enumerator(
at: root,
includingPropertiesForKeys: [.isExecutableKey],
options: [.skipsHiddenFiles]
) else { return nil }
for case let url as URL in enumerator {
guard url.lastPathComponent == name else { continue }
if fileManager.isExecutableFile(atPath: url.path) {
return url
}
}
return nil
}
public static func directoryByteCount(at url: URL, fileManager: FileManager = .default) -> Int64 {
guard let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey],
options: [.skipsHiddenFiles]
) else { return 0 }
var total: Int64 = 0
for case let fileURL as URL in enumerator {
let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
total += Int64(size)
}
return total
}
}
@@ -0,0 +1,492 @@
// LocalASRModelManager.swift
// OSGKeyboard · Shared
//
// Installs local ASR model archives and Sherpa runtimes under Application Support.
// Catalog is bundled; installed state is persisted in `installed-manifest.json`.
import Foundation
public struct LocalASRInstalledManifest: Codable, Sendable, Equatable {
public var schemaVersion: Int
public var selectedModelId: String
public var installedModelIDs: [String]
public var installedRuntimeIDs: [String]
public var updatedAt: Date
public init(
schemaVersion: Int = 1,
selectedModelId: String,
installedModelIDs: [String] = [],
installedRuntimeIDs: [String] = [],
updatedAt: Date = Date()
) {
self.schemaVersion = schemaVersion
self.selectedModelId = selectedModelId
self.installedModelIDs = installedModelIDs
self.installedRuntimeIDs = installedRuntimeIDs
self.updatedAt = updatedAt
}
}
public enum LocalASRModelInstallPhase: String, Sendable, Equatable {
case idle
case downloading
case paused
case extracting
case validating
case finalizing
case failed
case completed
}
public struct LocalASRModelInstallProgress: Sendable, Equatable {
public var phase: LocalASRModelInstallPhase
public var fraction: Double
public var message: String
public var bytesReceived: Int64?
public var bytesTotal: Int64?
public var activeItemId: String?
public init(
phase: LocalASRModelInstallPhase,
fraction: Double,
message: String,
bytesReceived: Int64? = nil,
bytesTotal: Int64? = nil,
activeItemId: String? = nil
) {
self.phase = phase
self.fraction = fraction
self.message = message
self.bytesReceived = bytesReceived
self.bytesTotal = bytesTotal
self.activeItemId = activeItemId
}
public static let idle = LocalASRModelInstallProgress(phase: .idle, fraction: 0, message: "")
}
public enum LocalASRModelManagerError: Error, LocalizedError {
case downloadFailed(String)
case extractFailed(String)
case validationFailed(String)
case runtimeMissing
case binaryMissing
public var errorDescription: String? {
switch self {
case .downloadFailed(let detail): return "Download failed: \(detail)"
case .extractFailed(let detail): return "Extract failed: \(detail)"
case .validationFailed(let detail): return "Validation failed: \(detail)"
case .runtimeMissing: return "Sherpa runtime is not installed."
case .binaryMissing: return "Sherpa binary not found in runtime bundle."
}
}
}
public actor LocalASRModelManager {
public static let shared = LocalASRModelManager()
private let fileManager = FileManager.default
private var progress = LocalASRModelInstallProgress.idle
#if os(macOS)
private var activeDownloadController: LocalASRModelDownloadController?
private var pausedResumeData: Data?
#endif
private init() {}
public func currentProgress() -> LocalASRModelInstallProgress {
progress
}
#if os(macOS)
public func pauseDownload() async throws {
guard progress.phase == .downloading, let controller = activeDownloadController else { return }
let resumeData = try await controller.pause()
pausedResumeData = resumeData
progress = LocalASRModelInstallProgress(
phase: .paused,
fraction: progress.fraction,
message: progress.message,
bytesReceived: progress.bytesReceived,
bytesTotal: progress.bytesTotal,
activeItemId: progress.activeItemId
)
}
public func resumeDownload() async throws {
guard progress.phase == .paused,
let resumeData = pausedResumeData,
let controller = activeDownloadController else {
throw LocalASRModelManagerError.downloadFailed("No paused download to resume.")
}
pausedResumeData = nil
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: progress.fraction,
message: progress.message,
bytesReceived: progress.bytesReceived,
bytesTotal: progress.bytesTotal,
activeItemId: progress.activeItemId
)
controller.resumeFromPause(resumeData)
}
public func isDownloadPaused() -> Bool {
progress.phase == .paused
}
#endif
public func rootDirectory() -> URL {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
}
public func manifestURL() -> URL {
LocalASRInstalledManifestIO.manifestURL(fileManager: fileManager)
}
public func loadManifest(defaultModelId: String) -> LocalASRInstalledManifest {
LocalASRInstalledManifestIO.load(defaultModelId: defaultModelId, fileManager: fileManager)
}
public func saveManifest(_ manifest: LocalASRInstalledManifest) throws {
try LocalASRInstalledManifestIO.save(manifest, fileManager: fileManager)
}
public func setSelectedModelId(_ modelId: String, catalog: LocalASRCatalogDocument) throws {
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try saveManifest(manifest)
}
public func installDirectory(for relativePath: String) -> URL {
rootDirectory().appendingPathComponent(relativePath, isDirectory: true)
}
public func isModelInstalled(_ model: LocalASRModelDefinition, manualMLXPath: String?) -> Bool {
LocalASRModelInstallState.isInstalled(model, manualMLXPath: manualMLXPath, fileManager: fileManager)
}
public func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
LocalASRModelInstallState.isRuntimeInstalled(runtime, fileManager: fileManager)
}
#if os(macOS)
public func resolveRuntimeBinary(runtime: LocalASRRuntimeDefinition) -> URL? {
LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime, fileManager: fileManager)
}
public func installModel(
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) async throws {
guard model.installKind == .archive,
let relative = model.installRelativePath,
let baseName = model.archiveBaseName,
let sources = model.sources,
!sources.isEmpty else {
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
}
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.05,
message: model.displayName,
activeItemId: model.id
)
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
try await ensureRuntimeInstalled(catalog: catalog)
}
let sortedSources = sources.sorted { $0.priority < $1.priority }
var lastError: Error?
for source in sortedSources {
do {
try await installArchive(
from: source.url,
installRelativePath: relative,
archiveBaseName: baseName,
layoutModel: model,
itemId: model.id,
displayName: model.displayName
)
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
if !manifest.installedModelIDs.contains(model.id) {
manifest.installedModelIDs.append(model.id)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
progress = LocalASRModelInstallProgress(
phase: .completed,
fraction: 1,
message: model.displayName,
activeItemId: model.id
)
return
} catch {
lastError = error
}
}
progress = LocalASRModelInstallProgress(
phase: .failed,
fraction: 0,
message: lastError?.localizedDescription ?? "Download failed"
)
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
}
public func installRuntime(
_ runtime: LocalASRRuntimeDefinition,
catalog: LocalASRCatalogDocument
) async throws {
guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
}
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.05,
message: runtime.displayName,
activeItemId: runtime.id
)
try await installArchive(
from: source.url,
installRelativePath: runtime.installRelativePath,
archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
layoutModel: nil,
expectedBinaryCandidates: runtime.binaryCandidates,
itemId: runtime.id,
displayName: runtime.displayName
)
guard isRuntimeInstalled(runtime) else {
throw LocalASRModelManagerError.binaryMissing
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
if !manifest.installedRuntimeIDs.contains(runtime.id) {
manifest.installedRuntimeIDs.append(runtime.id)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
}
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
LocalASRModelInstallState.modelRootURL(model, fileManager: fileManager)
}
public func installDirectoryURL(for model: LocalASRModelDefinition) -> URL? {
guard let relative = model.installRelativePath else { return nil }
return installDirectory(for: relative)
}
public func deleteModel(
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) throws {
guard model.installKind == .archive, let relative = model.installRelativePath else { return }
let dir = installDirectory(for: relative)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.installedModelIDs.removeAll { $0 == model.id }
if manifest.selectedModelId == model.id {
manifest.selectedModelId = catalog.defaultModelId
UserDefaults.standard.set(catalog.defaultModelId, forKey: LocalASRPreferenceKeys.selectedModelId)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
if progress.activeItemId == model.id {
progress = .idle
}
}
public func deleteRuntime(
_ runtime: LocalASRRuntimeDefinition,
catalog: LocalASRCatalogDocument
) throws {
let dir = installDirectory(for: runtime.installRelativePath)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.installedRuntimeIDs.removeAll { $0 == runtime.id }
manifest.updatedAt = Date()
try saveManifest(manifest)
}
private func setProgress(_ update: LocalASRModelInstallProgress) {
progress = update
}
func updateDownloadProgress(
itemId: String,
displayName: String,
update: LocalASRDownloadProgressUpdate
) {
reportDownloadProgress(itemId: itemId, displayName: displayName, update: update)
}
private func reportDownloadProgress(
itemId: String,
displayName: String,
update: LocalASRDownloadProgressUpdate
) {
// Download phase occupies 10%55% of the overall install bar.
let mapped = 0.10 + update.fraction * 0.45
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: mapped,
message: displayName,
bytesReceived: update.bytesReceived,
bytesTotal: update.bytesTotal,
activeItemId: itemId
)
}
#endif
// MARK: - Private
#if os(macOS)
private func installArchive(
from urlString: String,
installRelativePath: String,
archiveBaseName: String,
layoutModel: LocalASRModelDefinition?,
expectedBinaryCandidates: [String]? = nil,
itemId: String,
displayName: String
) async throws {
guard let remoteURL = URL(string: urlString) else {
throw LocalASRModelManagerError.downloadFailed("Invalid URL")
}
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
let destinationParent = installDirectory(for: installRelativePath)
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: stagingRoot) }
let archiveURL = stagingRoot.appendingPathComponent(remoteURL.lastPathComponent)
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.10,
message: displayName,
activeItemId: itemId
)
do {
let controller = LocalASRModelDownloadClient.makeController(destinationURL: archiveURL) { update in
Task {
await LocalASRModelManager.shared.updateDownloadProgress(
itemId: itemId,
displayName: displayName,
update: update
)
}
}
activeDownloadController = controller
try await controller.download(from: remoteURL)
activeDownloadController = nil
pausedResumeData = nil
} catch {
activeDownloadController = nil
pausedResumeData = nil
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
}
progress = LocalASRModelInstallProgress(
phase: .extracting,
fraction: 0.58,
message: displayName,
activeItemId: itemId
)
try fileManager.createDirectory(at: destinationParent, withIntermediateDirectories: true)
let extractOK = try await extractTarBz2(archiveURL: archiveURL, destination: destinationParent)
guard extractOK else {
throw LocalASRModelManagerError.extractFailed("tar extraction failed")
}
progress = LocalASRModelInstallProgress(
phase: .validating,
fraction: 0.82,
message: displayName,
activeItemId: itemId
)
if let layoutModel {
guard LocalASRModelInstallState.isInstalled(
layoutModel,
manualMLXPath: nil,
fileManager: fileManager
) else {
throw LocalASRModelManagerError.validationFailed("Required model files missing after extract.")
}
}
if let expectedBinaryCandidates {
let runtimeRoot = destinationParent
let found = expectedBinaryCandidates.contains { candidate in
let direct = runtimeRoot.appendingPathComponent(candidate)
if fileManager.isExecutableFile(atPath: direct.path) { return true }
let name = (candidate as NSString).lastPathComponent
return findExecutable(named: name, under: runtimeRoot) != nil
}
guard found else {
throw LocalASRModelManagerError.binaryMissing
}
}
progress = LocalASRModelInstallProgress(
phase: .finalizing,
fraction: 0.95,
message: displayName,
activeItemId: itemId
)
try? fileManager.removeItem(at: archiveURL)
}
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
guard let runtime = LocalASRModelCatalog.runtime(
for: LocalASRModelCatalog.currentRuntimePlatform(),
in: catalog
) else {
throw LocalASRModelManagerError.runtimeMissing
}
if isRuntimeInstalled(runtime) { return }
try await installRuntime(runtime, catalog: catalog)
}
private func findExecutable(named name: String, under root: URL) -> URL? {
guard let enumerator = fileManager.enumerator(
at: root,
includingPropertiesForKeys: [.isExecutableKey],
options: [.skipsHiddenFiles]
) else { return nil }
for case let url as URL in enumerator {
guard url.lastPathComponent == name else { continue }
if fileManager.isExecutableFile(atPath: url.path) {
return url
}
}
return nil
}
private func extractTarBz2(archiveURL: URL, destination: URL) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
process.arguments = ["-xjf", archiveURL.path, "-C", destination.path]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus == 0)
}
do {
try process.run()
} catch {
continuation.resume(throwing: LocalASRModelManagerError.extractFailed(error.localizedDescription))
}
}
}
#endif
}
@@ -0,0 +1,8 @@
// LocalASRPreferenceKeys.swift
// OSGKeyboard · Shared
import Foundation
enum LocalASRPreferenceKeys {
static let selectedModelId = "mac.localASR.selectedModelId"
}
@@ -0,0 +1,68 @@
// LocalASRTranscriptCorrector.swift
// OSGKeyboard · Shared
//
// Deterministic alias canonical term replacement between raw ASR output
// and the LLM polish step. Only applies whole-phrase matches.
import Foundation
public enum LocalASRTranscriptCorrector {
/// Applies high-confidence alias replacements (longest match first).
public static func apply(
_ text: String,
pairs: [LocalASRCorrectionPair]
) -> String {
guard !text.isEmpty, !pairs.isEmpty else { return text }
let sorted = pairs.sorted { lhs, rhs in
if lhs.alias.count != rhs.alias.count {
return lhs.alias.count > rhs.alias.count
}
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
}
var result = text
for pair in sorted {
result = replaceWholeMatches(
in: result,
alias: pair.alias,
term: pair.term
)
}
return result
}
// MARK: - Private
private static func replaceWholeMatches(
in text: String,
alias: String,
term: String
) -> String {
guard !alias.isEmpty, alias != term else { return text }
if alias.unicodeScalars.allSatisfy({ $0.isASCII }) {
return replaceASCIIWord(in: text, alias: alias, term: term)
}
return text.replacingOccurrences(of: alias, with: term)
}
private static func replaceASCIIWord(
in text: String,
alias: String,
term: String
) -> String {
let escaped = NSRegularExpression.escapedPattern(for: alias)
let pattern = "(?i)(?<![A-Za-z0-9_])\(escaped)(?![A-Za-z0-9_])"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return text
}
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regex.stringByReplacingMatches(
in: text,
range: range,
withTemplate: term
)
}
}
@@ -252,7 +252,10 @@ public actor PolishingService {
providerId: String
) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
let dictionaryBlock = Self.mergedDictionaryBlock(
dictionary: dictionary,
supplement: context.dictionarySupplement
)
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
@@ -321,6 +324,17 @@ public actor PolishingService {
}
}
internal static func mergedDictionaryBlock(
dictionary: PersonalDictionary,
supplement: String?
) -> String {
let base = dictionary.promptFragment()
let extra = supplement?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if base.isEmpty { return extra }
if extra.isEmpty { return base }
return base + "\n" + extra
}
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
+42 -2
View File
@@ -157,8 +157,8 @@
"mac.settings.recognition" = "RECOGNITION METHOD";
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
"mac.settings.localEngine" = "Local Recognition (Qwen3-ASR)";
"mac.settings.localEngineDesc" = "On-device ASR with Qwen3-ASR 1.7B (MLX). High privacy, zero latency.";
"mac.settings.localEngine" = "Local Recognition";
"mac.settings.localEngineDesc" = "On-device transcription with a local model. High privacy, zero latency.";
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
"mac.settings.about" = "About";
@@ -174,6 +174,9 @@
"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
"mac.settings.qwen3Browse" = "Choose folder…";
"mac.settings.qwen3Missing" = "Qwen3 model not found — using Apple Speech for now.";
"mac.settings.mlxModelMissing" = "Select a Qwen3 MLX model folder below, or choose another installed model.";
"mac.settings.selectedModelMissing" = "%@ is not installed — using Apple Speech for now.";
"mac.settings.localModelFallbackApple" = "No local model is ready — using Apple Speech for now.";
"mac.settings.accessibility" = "Accessibility";
"mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste.";
"mac.settings.openAccessibility" = "Open System Settings";
@@ -188,6 +191,43 @@
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
"mac.localASR.models" = "Local ASR Models";
"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory.";
"mac.localASR.download" = "Download";
"mac.localASR.selectFolder" = "Choose folder";
"mac.localASR.openFolder" = "Open folder";
"mac.localASR.pause" = "Pause";
"mac.localASR.resume" = "Resume";
"mac.localASR.needsFolder" = "Folder required";
"mac.localASR.installDone" = "Install completed.";
"mac.localASR.installed" = "Installed";
"mac.localASR.notInstalled" = "Not installed";
"mac.localASR.hotwordsYes" = "Hotwords";
"mac.localASR.hotwordsNo" = "No hotwords";
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
"mac.localASR.diagnostics" = "Last Bias Diagnostics";
"mac.localASR.diagnosticsDesc" = "Captured after your most recent local dictation.";
"mac.localASR.diagEmpty" = "No local dictation yet.";
"mac.localASR.diagBackend" = "Backend";
"mac.localASR.diagUserTerms" = "User terms";
"mac.localASR.diagBuiltinTerms" = "Builtin terms";
"mac.localASR.diagHotwords" = "Hotwords sent";
"mac.localASR.diagPrompt" = "Prompt chars";
"mac.localASR.diagTruncated" = "Prompt truncated";
"mac.localASR.delete" = "Delete";
"mac.localASR.deleteDone" = "Model deleted.";
"mac.localASR.redownload" = "Re-download";
"mac.localASR.revealInFinder" = "Reveal in Finder";
"mac.localASR.openStorage" = "Open model storage folder";
"mac.localASR.runtime" = "Sherpa Runtime";
"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models.";
"mac.localASR.phase.downloading" = "Downloading";
"mac.localASR.phase.paused" = "Paused";
"mac.localASR.phase.extracting" = "Extracting";
"mac.localASR.phase.validating" = "Validating";
"mac.localASR.phase.finalizing" = "Finalizing";
"mac.localASR.phase.failed" = "Failed";
"mac.localASR.phase.completed" = "Completed";
"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
"mac.foregroundApp" = "Front app: %@";
"mac.sync.settingsTitle" = "iCloud Sync";
+42 -2
View File
@@ -157,8 +157,8 @@
"mac.settings.recognition" = "识别方式";
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
"mac.settings.localEngine" = "本地识别Qwen3-ASR";
"mac.settings.localEngineDesc" = "使用 Qwen3-ASR 1.7BMLX)本地转写,高隐私、低延迟。";
"mac.settings.localEngine" = "本地识别";
"mac.settings.localEngineDesc" = "在本机使用本地模型转写,高隐私、低延迟。";
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech";
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
"mac.settings.about" = "关于";
@@ -174,6 +174,9 @@
"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
"mac.settings.qwen3Browse" = "选择文件夹…";
"mac.settings.qwen3Missing" = "未找到 Qwen3 模型,暂时使用 Apple Speech。";
"mac.settings.mlxModelMissing" = "请在下方选择 Qwen3 MLX 模型目录,或改用其他已安装的模型。";
"mac.settings.selectedModelMissing" = "「%@」尚未安装,暂时使用 Apple Speech。";
"mac.settings.localModelFallbackApple" = "没有可用的本地模型,暂时使用 Apple Speech。";
"mac.settings.accessibility" = "辅助功能";
"mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。";
"mac.settings.openAccessibility" = "打开系统设置";
@@ -188,6 +191,43 @@
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
"mac.localASR.models" = "本地 ASR 模型";
"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。";
"mac.localASR.download" = "下载";
"mac.localASR.selectFolder" = "选择目录";
"mac.localASR.openFolder" = "打开目录";
"mac.localASR.pause" = "暂停";
"mac.localASR.resume" = "继续";
"mac.localASR.needsFolder" = "需选择目录";
"mac.localASR.installDone" = "安装完成。";
"mac.localASR.installed" = "已安装";
"mac.localASR.notInstalled" = "未安装";
"mac.localASR.hotwordsYes" = "支持热词";
"mac.localASR.hotwordsNo" = "无热词";
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
"mac.localASR.diagnostics" = "最近一次词库诊断";
"mac.localASR.diagnosticsDesc" = "在上一轮本地听写后记录。";
"mac.localASR.diagEmpty" = "尚无本地听写记录。";
"mac.localASR.diagBackend" = "后端";
"mac.localASR.diagUserTerms" = "用户词条";
"mac.localASR.diagBuiltinTerms" = "内置词条";
"mac.localASR.diagHotwords" = "热词数量";
"mac.localASR.diagPrompt" = "Prompt 字符";
"mac.localASR.diagTruncated" = "Prompt 已截断";
"mac.localASR.delete" = "删除";
"mac.localASR.deleteDone" = "模型已删除。";
"mac.localASR.redownload" = "重新下载";
"mac.localASR.revealInFinder" = "在 Finder 中显示";
"mac.localASR.openStorage" = "打开模型存储目录";
"mac.localASR.runtime" = "Sherpa 运行时";
"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。";
"mac.localASR.phase.downloading" = "下载中";
"mac.localASR.phase.paused" = "已暂停";
"mac.localASR.phase.extracting" = "解压中";
"mac.localASR.phase.validating" = "校验中";
"mac.localASR.phase.finalizing" = "完成安装";
"mac.localASR.phase.failed" = "失败";
"mac.localASR.phase.completed" = "已完成";
"mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能";
"mac.foregroundApp" = "前台应用:%@";
"mac.sync.settingsTitle" = "iCloud 同步";