200265fbd6
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).
105 lines
3.8 KiB
Swift
105 lines
3.8 KiB
Swift
// MacDictationPipeline.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Dictation pipeline: samples → ASR (cloud or local) → polish.
|
|
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
|
|
// with Apple Speech fallback when weights are missing.
|
|
|
|
import Foundation
|
|
|
|
enum MacDictationError: Error, LocalizedError {
|
|
case noAudio
|
|
case providerHasNoCloudASR
|
|
case emptyTranscript
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noAudio:
|
|
return MacL10n.string("mac.error.noAudio")
|
|
case .providerHasNoCloudASR:
|
|
return MacL10n.string("mac.error.noCloudASR")
|
|
case .emptyTranscript:
|
|
return MacL10n.string("mac.error.emptyTranscript")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MacDictationPipeline {
|
|
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
|
|
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
|
|
guard !samples.isEmpty else { throw MacDictationError.noAudio }
|
|
|
|
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
|
let raw: String
|
|
var localBias: LocalASRBiasPayload?
|
|
|
|
if store.engineMode == "local" {
|
|
MacAppContextService.captureAndPersist(to: store)
|
|
let capabilities = MacLocalASRService.currentCapabilities()
|
|
let bias = LocalASRBiasAdapter.adapt(
|
|
LocalASRBiasRequest(
|
|
dictionary: store.personalDictionary,
|
|
locale: locale,
|
|
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
|
capabilities: capabilities
|
|
)
|
|
)
|
|
localBias = bias
|
|
LocalASRBiasDiagnosticsStore.save(
|
|
payload: bias,
|
|
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
|
backendLabel: MacLocalASRService.currentBackendLabel()
|
|
)
|
|
raw = try await MacLocalASRService.transcribe(
|
|
samples: samples,
|
|
locale: locale,
|
|
bias: bias
|
|
)
|
|
} else {
|
|
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
|
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
|
|
|
|
let client = CloudASRClientFactory.make(store: store)
|
|
try? await client.prepare(dictionary: store.personalDictionary)
|
|
raw = try await client.transcribe(
|
|
samples: samples,
|
|
sampleRate: 16_000,
|
|
locale: locale,
|
|
dictionary: store.personalDictionary
|
|
)
|
|
}
|
|
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
|
|
|
|
let postASR: String
|
|
if let localBias, !localBias.correctionPairs.isEmpty {
|
|
postASR = LocalASRTranscriptCorrector.apply(trimmed, pairs: localBias.correctionPairs)
|
|
} else {
|
|
postASR = trimmed
|
|
}
|
|
|
|
let polishContext: PolishContext?
|
|
if let supplement = localBias?.polishFragment.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!supplement.isEmpty {
|
|
polishContext = PolishContext(
|
|
appContext: store.detectedAppContext?.context ?? .unknown,
|
|
intensity: store.polishIntensity,
|
|
dictionarySupplement: supplement
|
|
)
|
|
} else {
|
|
polishContext = nil
|
|
}
|
|
|
|
if let polished = try? await PolishingService(store: store).polish(
|
|
postASR,
|
|
mode: store.polishModeForPipeline,
|
|
context: polishContext
|
|
),
|
|
!polished.isEmpty {
|
|
return polished
|
|
}
|
|
return postASR
|
|
}
|
|
}
|