perf(asr): speed up local Flow dictation and land CLM/keyboard refactor
Reduce perceived latency from key release to final text: - Adaptive chunking: 2.5s first chunk + 5s follow-ups so short utterances start on-device recognition while still recording. - Session-level ASR warmup and audio-format cache reuse to remove per-utterance cold-start of SpeechAnalyzer. - Mirror live pipelined partials to the keyboard transcript line via a new flow.transcriptionPartial App Group key + Darwin ping. Also commits the accumulated custom language model, Flow session, keyboard extension restructure, and Xiaomi MiMo provider work in progress on this branch.
This commit is contained in:
@@ -23,6 +23,15 @@ public enum AppGroup {
|
||||
) != nil
|
||||
}()
|
||||
|
||||
/// Shared UserDefaults when the App Group suite is available; `nil` otherwise.
|
||||
///
|
||||
/// Prefer this in Release builds and in the keyboard extension so callers
|
||||
/// can surface a setup error instead of silently reading/writing the wrong suite.
|
||||
public static var defaultsIfAvailable: UserDefaults? {
|
||||
guard isAvailable else { return nil }
|
||||
return UserDefaults(suiteName: identifier)
|
||||
}
|
||||
|
||||
/// Shared UserDefaults instance for cross-process config.
|
||||
///
|
||||
/// In DEBUG builds a missing App Group is a hard `fatalError`: silently
|
||||
@@ -32,33 +41,30 @@ public enum AppGroup {
|
||||
/// the App an API key and nothing happens" — which is exactly the bug
|
||||
/// this is meant to prevent.
|
||||
///
|
||||
/// In release builds we keep the soft fallback + `NSLog` so an
|
||||
/// end-user whose developer account simply lacks the App Group still
|
||||
/// gets a usable main App (the keyboard extension won't work, but at
|
||||
/// least the App doesn't crash on launch).
|
||||
/// In Release builds there is **no** `.standard` fallback — use
|
||||
/// `defaultsIfAvailable` and handle `nil` when provisioning is missing.
|
||||
public static var defaults: UserDefaults {
|
||||
if let d = UserDefaults(suiteName: identifier) {
|
||||
return d
|
||||
guard let suite = defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("""
|
||||
⚠️ App Group \(identifier) unavailable.
|
||||
|
||||
Add the App Group in:
|
||||
1. Apple Developer portal → Identifiers → App Groups → add
|
||||
\(identifier)
|
||||
2. Both bundle IDs (main app + keyboard extension) → enable
|
||||
that App Group under Capabilities
|
||||
3. Re-generate the provisioning profile, download it, and
|
||||
re-run the project.
|
||||
|
||||
Falling back to .standard would silently desync the keyboard
|
||||
extension from the main App — a hard crash in DEBUG is the
|
||||
only way to make the misconfiguration impossible to miss.
|
||||
""")
|
||||
#else
|
||||
fatalError("App Group \(identifier) unavailable. Check entitlements and provisioning.")
|
||||
#endif
|
||||
}
|
||||
#if DEBUG
|
||||
fatalError("""
|
||||
⚠️ App Group \(identifier) unavailable.
|
||||
|
||||
Add the App Group in:
|
||||
1. Apple Developer portal → Identifiers → App Groups → add
|
||||
\(identifier)
|
||||
2. Both bundle IDs (main app + keyboard extension) → enable
|
||||
that App Group under Capabilities
|
||||
3. Re-generate the provisioning profile, download it, and
|
||||
re-run the project.
|
||||
|
||||
Falling back to .standard would silently desync the keyboard
|
||||
extension from the main App — a hard crash in DEBUG is the
|
||||
only way to make the misconfiguration impossible to miss.
|
||||
""")
|
||||
#else
|
||||
NSLog("⚠️ [OSGKeyboard] App Group \(identifier) unavailable, falling back to .standard. The keyboard extension will not see config written by the main app.")
|
||||
return .standard
|
||||
#endif
|
||||
return suite
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// RecordButton.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tap-to-toggle mic button shared between the keyboard extension and
|
||||
// host-app keyboard preview surfaces.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RecordButton: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case processing
|
||||
case error
|
||||
}
|
||||
|
||||
public let phase: Phase
|
||||
public let level: Double
|
||||
public let remainingSeconds: Int?
|
||||
public let isEnabled: Bool
|
||||
public let onToggle: () -> Void
|
||||
|
||||
@State private var breath = false
|
||||
|
||||
public init(
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
remainingSeconds: Int? = nil,
|
||||
isEnabled: Bool = true,
|
||||
onToggle: @escaping () -> Void
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.isEnabled = isEnabled
|
||||
self.onToggle = onToggle
|
||||
}
|
||||
|
||||
private var isUrgent: Bool {
|
||||
guard phase == .recording, let remainingSeconds else { return false }
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 95
|
||||
static let outerRing: CGFloat = 106
|
||||
static let breathRing: CGFloat = 100
|
||||
static let glow: CGFloat = 119
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: Layout.breathRing, height: Layout.breathRing)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [palette.recordRed.opacity(0.55), .clear],
|
||||
center: .center,
|
||||
startRadius: 46,
|
||||
endRadius: 92
|
||||
)
|
||||
)
|
||||
.frame(width: Layout.glow, height: Layout.glow)
|
||||
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
||||
.blur(radius: 18)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: level)
|
||||
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(discGradient)
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
.blendMode(.overlay)
|
||||
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
case .recording:
|
||||
VStack(spacing: 3) {
|
||||
if let remainingSeconds {
|
||||
Text(formatRemaining(remainingSeconds))
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
.offset(y: 3)
|
||||
}
|
||||
WaveformView(
|
||||
level: level,
|
||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||
active: true
|
||||
)
|
||||
.frame(width: 73, height: 32)
|
||||
.opacity(0.4)
|
||||
.scaleEffect(0.96)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(palette.textPrimary)
|
||||
.scaleEffect(1.25)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: Layout.disc, height: Layout.disc)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.opacity(isEnabled ? 1 : 0.45)
|
||||
.onTapGesture {
|
||||
guard isEnabled, phase != .processing else { return }
|
||||
onToggle()
|
||||
}
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
.onChange(of: phase) { _, new in
|
||||
breath = (new == .recording)
|
||||
}
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||
}
|
||||
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
let minutes = seconds / 60
|
||||
let remainder = seconds % 60
|
||||
return String(format: "%d:%02d", minutes, remainder)
|
||||
}
|
||||
|
||||
private var discGradient: LinearGradient {
|
||||
switch phase {
|
||||
case .recording:
|
||||
let colors: [Color] = isUrgent
|
||||
? [palette.recordRed, palette.recordRed.opacity(0.85)]
|
||||
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
|
||||
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
case .processing:
|
||||
return LinearGradient(
|
||||
colors: [palette.surfaceElevated, palette.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .error:
|
||||
return LinearGradient(
|
||||
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .idle:
|
||||
return LinearGradient(
|
||||
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// TranslationChip.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Translation target picker chip shared between keyboard extension and
|
||||
// host-app preview surfaces.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct TranslationChip: View, Equatable {
|
||||
public let palette: ThemePalette
|
||||
public let targetLocaleId: String
|
||||
public let onSelect: (String) -> Void
|
||||
|
||||
public init(
|
||||
palette: ThemePalette,
|
||||
targetLocaleId: String,
|
||||
onSelect: @escaping (String) -> Void
|
||||
) {
|
||||
self.palette = palette
|
||||
self.targetLocaleId = targetLocaleId
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
nonisolated public static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
|
||||
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
onSelect(language.id)
|
||||
} label: {
|
||||
if language.id == targetLocaleId {
|
||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: language))
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
||||
Text(chipLabel(target: target, enabled: enabled))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(foreground(enabled: enabled))
|
||||
.padding(.horizontal, Spacing.xs + 2)
|
||||
.padding(.vertical, 6)
|
||||
.frame(minHeight: 28)
|
||||
.background(background(enabled: enabled), in: Capsule())
|
||||
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return SharedL10n.string("keyboard.translation.offMenu")
|
||||
}
|
||||
return language.nativeName
|
||||
}
|
||||
|
||||
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||
if !enabled {
|
||||
return SharedL10n.string("keyboard.translation.chip")
|
||||
}
|
||||
return "→\(shortLabel(for: target))"
|
||||
}
|
||||
|
||||
private func shortLabel(for target: TranslationLanguage) -> String {
|
||||
switch target.id {
|
||||
case "en": return "EN"
|
||||
case "zh-Hans": return "中"
|
||||
case "zh-Hant": return "繁"
|
||||
case "ja": return "日"
|
||||
case "ko": return "韩"
|
||||
case "fr": return "FR"
|
||||
case "de": return "DE"
|
||||
case "es": return "ES"
|
||||
case "ru": return "RU"
|
||||
case "pt": return "PT"
|
||||
default: return target.promptLanguageName
|
||||
}
|
||||
}
|
||||
|
||||
private func foreground(enabled: Bool) -> Color {
|
||||
enabled ? palette.accent : palette.textPrimary
|
||||
}
|
||||
|
||||
private func background(enabled: Bool) -> Color {
|
||||
enabled ? palette.accent.opacity(0.15) : palette.surfaceElevated
|
||||
}
|
||||
|
||||
private func stroke(enabled: Bool) -> Color {
|
||||
enabled ? palette.accent.opacity(0.35) : palette.divider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// WaveformView.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Symmetric, real-time driven waveform. Shared between the keyboard
|
||||
// extension and any host-app preview that mirrors the mic UI.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct WaveformView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
public let level: Double
|
||||
public let barCount: Int
|
||||
public let color: Color?
|
||||
public let active: Bool
|
||||
|
||||
public init(
|
||||
level: Double,
|
||||
barCount: Int = 18,
|
||||
color: Color? = nil,
|
||||
active: Bool = true
|
||||
) {
|
||||
self.level = max(0, min(1, level))
|
||||
self.barCount = barCount
|
||||
self.color = color
|
||||
self.active = active
|
||||
}
|
||||
|
||||
private var resolvedColor: Color {
|
||||
color ?? palette.recordRed
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(resolvedColor)
|
||||
.frame(
|
||||
width: 2.4,
|
||||
height: height(for: index, time: context.date.timeIntervalSinceReferenceDate)
|
||||
)
|
||||
.opacity(active ? 1.0 : 0.45)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, time: TimeInterval) -> CGFloat {
|
||||
guard active else { return 4 }
|
||||
let centre = Double(barCount - 1) / 2.0
|
||||
let distance = abs(Double(index) - centre) / max(centre, 1)
|
||||
let phase = sin(time * 4.0 + Double(index) * 0.45)
|
||||
let wobble = 0.18 * phase
|
||||
let magnitude = max(0, min(1, Double(level) + wobble))
|
||||
let profile = 1.0 - pow(distance, 1.4) * 0.85
|
||||
return CGFloat(max(6, 32 * magnitude * profile))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// AppGroupConfiguration.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single source of truth for App Group UserDefaults keys (`config.*`).
|
||||
// Both `ProviderConfig` (main app) and `AppGroupStore` (keyboard ext)
|
||||
// should read/write through this type so keys and defaults stay aligned.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
// MARK: - Keys
|
||||
|
||||
public enum Keys {
|
||||
public static let providerId = "config.providerId"
|
||||
public static let baseURL = "config.baseURL"
|
||||
/// Legacy plaintext slot — migrated to Keychain on first read.
|
||||
public static let apiKeyLegacy = "config.apiKey"
|
||||
public static let model = "config.model"
|
||||
public static let modeId = "config.modeId"
|
||||
public static let localeId = "config.localeId"
|
||||
public static let engineMode = "config.engineMode"
|
||||
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||
public static let onboardingPage = "config.onboardingPage"
|
||||
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||
public static let uiLanguage = "config.uiLanguage"
|
||||
public static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
public static let handednessPreference = "config.handednessPreference"
|
||||
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
public static let polishIntensity = "config.polishIntensity"
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
}
|
||||
|
||||
// MARK: - Stored fields
|
||||
|
||||
public var providerId: String
|
||||
public var baseURL: String
|
||||
public var model: String
|
||||
public var modeId: String
|
||||
public var localeId: String
|
||||
public var engineMode: String
|
||||
public var hasCompletedOnboarding: Bool
|
||||
public var onboardingPage: Int
|
||||
public var hasAcknowledgedCloudSharing: Bool
|
||||
public var uiLanguage: AppUILanguage
|
||||
public var translationTargetLocaleId: String
|
||||
public var handednessPreference: HandednessPreference
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var personalDictionary: PersonalDictionary
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
/// Translation is on iff a target locale other than `offLocaleId` is selected.
|
||||
public var translationEnabled: Bool {
|
||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
}
|
||||
|
||||
public var isLocalEngine: Bool {
|
||||
engineMode == "local"
|
||||
}
|
||||
|
||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||
isTranslationEffective
|
||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||
: .polish
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Detected app context
|
||||
|
||||
public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? {
|
||||
guard let raw = defaults.string(forKey: Keys.detectedAppContext),
|
||||
let value = AppContext(rawValue: raw)
|
||||
else { return nil }
|
||||
let timestamp = defaults.object(forKey: Keys.detectedAppContextAt) as? Date ?? .distantPast
|
||||
return (value, timestamp)
|
||||
}
|
||||
|
||||
public mutating func setDetectedAppContext(_ context: AppContext, at date: Date = Date(), to defaults: UserDefaults) {
|
||||
defaults.set(context.rawValue, forKey: Keys.detectedAppContext)
|
||||
defaults.set(date, forKey: Keys.detectedAppContextAt)
|
||||
}
|
||||
|
||||
// MARK: - Load / save
|
||||
|
||||
/// Loads configuration from App Group defaults. Returns `nil` when the suite is unavailable.
|
||||
public static func load(from defaults: UserDefaults? = nil) -> AppGroupConfiguration? {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
||||
return load(fromAvailable: store)
|
||||
}
|
||||
|
||||
/// Loads configuration from a known-available UserDefaults suite.
|
||||
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
||||
var config = AppGroupConfiguration(
|
||||
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
||||
baseURL: "",
|
||||
model: "",
|
||||
modeId: defaults.string(forKey: Keys.modeId) ?? "polish",
|
||||
localeId: defaults.string(forKey: Keys.localeId) ?? "auto",
|
||||
engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud",
|
||||
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
|
||||
onboardingPage: {
|
||||
let saved = defaults.integer(forKey: Keys.onboardingPage)
|
||||
return saved > 0 ? saved : 0
|
||||
}(),
|
||||
hasAcknowledgedCloudSharing: defaults.bool(forKey: Keys.hasAcknowledgedCloudSharing),
|
||||
uiLanguage: AppUILanguage.fromStored(defaults.string(forKey: Keys.uiLanguage)),
|
||||
translationTargetLocaleId: defaults.string(forKey: Keys.translationTargetLocaleId)
|
||||
?? TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: HandednessPreference.fromStored(
|
||||
defaults.string(forKey: Keys.handednessPreference)
|
||||
),
|
||||
cursorDragNavigationEnabled: {
|
||||
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults)
|
||||
)
|
||||
|
||||
let preset = LLMProvider.provider(id: config.providerId)
|
||||
if config.baseURL.isEmpty {
|
||||
config.baseURL = defaults.string(forKey: Keys.baseURL) ?? preset.defaultBaseURL
|
||||
}
|
||||
if config.model.isEmpty {
|
||||
config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if config.engineMode == "cloud", config.modeId != "polish" {
|
||||
config.modeId = "polish"
|
||||
defaults.set("polish", forKey: Keys.modeId)
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if config.engineMode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
defaults.set(openAI.id, forKey: Keys.providerId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
public func save(to defaults: UserDefaults) {
|
||||
defaults.set(providerId, forKey: Keys.providerId)
|
||||
defaults.set(baseURL, forKey: Keys.baseURL)
|
||||
defaults.set(model, forKey: Keys.model)
|
||||
defaults.set(modeId, forKey: Keys.modeId)
|
||||
defaults.set(localeId, forKey: Keys.localeId)
|
||||
defaults.set(engineMode, forKey: Keys.engineMode)
|
||||
defaults.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding)
|
||||
defaults.set(onboardingPage, forKey: Keys.onboardingPage)
|
||||
defaults.set(hasAcknowledgedCloudSharing, forKey: Keys.hasAcknowledgedCloudSharing)
|
||||
defaults.set(uiLanguage.rawValue, forKey: Keys.uiLanguage)
|
||||
defaults.set(translationTargetLocaleId, forKey: Keys.translationTargetLocaleId)
|
||||
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private static func resolvePolishIntensity(from defaults: UserDefaults) -> PolishIntensity {
|
||||
guard let raw = defaults.string(forKey: Keys.polishIntensity) else {
|
||||
return .default
|
||||
}
|
||||
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
defaults.set(resolved.rawValue, forKey: Keys.polishIntensity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
|
||||
guard let data = defaults.data(forKey: Keys.personalDictionary) else {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||
dictionary.entries[index].source = .manual
|
||||
}
|
||||
dictionary.version += 1
|
||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||
defaults.set(migrated, forKey: Keys.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
} catch {
|
||||
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodePersonalDictionary(_ dictionary: PersonalDictionary, to defaults: UserDefaults) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(dictionary)
|
||||
defaults.set(data, forKey: Keys.personalDictionary)
|
||||
} catch {
|
||||
OSGLog.config.warning("personalDictionary encode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
||||
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let defaults,
|
||||
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,11 @@ public enum EngineServiceLabel {
|
||||
engineMode: String,
|
||||
providerId: String,
|
||||
model: String,
|
||||
localASRBackend: LocalASRBackend = .speechAnalyzer,
|
||||
language: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let lang = language ?? AppGroupStore().uiLanguage
|
||||
if engineMode == "local" {
|
||||
let asrName = asrDisplayName(for: localASRBackend, language: lang)
|
||||
let asrName = SharedL10n.string("engine.asr.appleSpeech", language: lang)
|
||||
return SharedL10n.format("engine.summary.local", language: lang, asrName)
|
||||
}
|
||||
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
|
||||
@@ -30,14 +29,4 @@ public enum EngineServiceLabel {
|
||||
trimmedModel
|
||||
)
|
||||
}
|
||||
|
||||
private static func asrDisplayName(
|
||||
for backend: LocalASRBackend,
|
||||
language: AppUILanguage
|
||||
) -> String {
|
||||
// v0.2.0: only the iOS SpeechAnalyzer path remains. We keep the
|
||||
// switch on `LocalASRBackend` so the next non-iOS backend can
|
||||
// slot in without touching every call site.
|
||||
return SharedL10n.string("engine.asr.appleSpeech", language: language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import Foundation
|
||||
|
||||
public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||
/// Target maximum duration per ASR chunk.
|
||||
public let maxChunkDurationSeconds: TimeInterval
|
||||
/// Target duration for the first ASR chunk (starts pipelining early).
|
||||
public let firstChunkDurationSeconds: TimeInterval
|
||||
/// Target duration for later chunks once pipelining is underway.
|
||||
public let subsequentChunkDurationSeconds: TimeInterval
|
||||
/// Tail overlap fed into the next chunk for boundary dedup when stitching.
|
||||
public let overlapDurationSeconds: TimeInterval
|
||||
/// After hitting the max window, wait up to this long for a pause before hard-splitting.
|
||||
@@ -17,21 +19,52 @@ public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||
public let sampleRate: Int
|
||||
|
||||
public init(
|
||||
maxChunkDurationSeconds: TimeInterval,
|
||||
firstChunkDurationSeconds: TimeInterval = 2.5,
|
||||
subsequentChunkDurationSeconds: TimeInterval = 5.0,
|
||||
overlapDurationSeconds: TimeInterval,
|
||||
pauseExtensionMaxSeconds: TimeInterval,
|
||||
pauseRMSThreshold: Float,
|
||||
sampleRate: Int
|
||||
) {
|
||||
self.maxChunkDurationSeconds = maxChunkDurationSeconds
|
||||
self.firstChunkDurationSeconds = firstChunkDurationSeconds
|
||||
self.subsequentChunkDurationSeconds = subsequentChunkDurationSeconds
|
||||
self.overlapDurationSeconds = overlapDurationSeconds
|
||||
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
||||
self.pauseRMSThreshold = pauseRMSThreshold
|
||||
self.sampleRate = sampleRate
|
||||
}
|
||||
|
||||
/// Uniform chunk size — used by unit tests and legacy call sites.
|
||||
public init(
|
||||
maxChunkDurationSeconds: TimeInterval,
|
||||
overlapDurationSeconds: TimeInterval,
|
||||
pauseExtensionMaxSeconds: TimeInterval,
|
||||
pauseRMSThreshold: Float,
|
||||
sampleRate: Int
|
||||
) {
|
||||
self.firstChunkDurationSeconds = maxChunkDurationSeconds
|
||||
self.subsequentChunkDurationSeconds = maxChunkDurationSeconds
|
||||
self.overlapDurationSeconds = overlapDurationSeconds
|
||||
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
||||
self.pauseRMSThreshold = pauseRMSThreshold
|
||||
self.sampleRate = sampleRate
|
||||
}
|
||||
|
||||
/// Backward-compatible alias for tests that read `maxChunkSamples`.
|
||||
public var maxChunkDurationSeconds: TimeInterval {
|
||||
subsequentChunkDurationSeconds
|
||||
}
|
||||
|
||||
public func maxChunkDurationSeconds(forChunkIndex index: Int) -> TimeInterval {
|
||||
index == 0 ? firstChunkDurationSeconds : subsequentChunkDurationSeconds
|
||||
}
|
||||
|
||||
public func maxChunkSamples(forChunkIndex index: Int) -> Int {
|
||||
Int(maxChunkDurationSeconds(forChunkIndex: index) * Double(sampleRate))
|
||||
}
|
||||
|
||||
public var maxChunkSamples: Int {
|
||||
Int(maxChunkDurationSeconds * Double(sampleRate))
|
||||
maxChunkSamples(forChunkIndex: 1)
|
||||
}
|
||||
|
||||
public var overlapSamples: Int {
|
||||
@@ -44,7 +77,8 @@ public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||
|
||||
/// Default for keyboard Flow utterances (≤ 3 min, pipelined ASR).
|
||||
public static let flowDefault = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 30,
|
||||
firstChunkDurationSeconds: 2.5,
|
||||
subsequentChunkDurationSeconds: 5.0,
|
||||
overlapDurationSeconds: 0.5,
|
||||
pauseExtensionMaxSeconds: 2,
|
||||
pauseRMSThreshold: 0.015,
|
||||
|
||||
@@ -82,6 +82,14 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
|
||||
blurb: "Kimi · 长上下文 · Long context"
|
||||
),
|
||||
.init(
|
||||
id: "mimo",
|
||||
name: "小米 MiMo",
|
||||
defaultBaseURL: "https://api.xiaomimimo.com/v1",
|
||||
defaultModel: "mimo-v2.5",
|
||||
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
|
||||
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized"
|
||||
),
|
||||
.init(
|
||||
id: "custom",
|
||||
name: "Custom · 自定义",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// LocalASRBackend.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Identifies which on-device speech recognition engine to use when the
|
||||
// user picks the "local" engine (no cloud LLM polish). The shared
|
||||
// factory `ASRServiceFactory` dispatches on this enum; the settings UI
|
||||
// renders it as a picker.
|
||||
//
|
||||
// As of v0.2.0 the only on-device backend is iOS 26 `SpeechAnalyzer`
|
||||
// + `DictationTranscriber`. The previous Qwen3-CoreML backend has
|
||||
// been removed: that path required a ~1.6 GB CoreML bundle, a local
|
||||
// SPM fork that pulled in mlx-swift, and significant app-side state
|
||||
// (download manager, warm-up service, model registry). We now keep the
|
||||
// local engine narrow — same iOS ASR the cloud engine already uses —
|
||||
// and let users opt into a cloud polish step after the transcript is
|
||||
// produced if they need stronger accuracy on noisy audio or dialectal
|
||||
// Chinese. See `LocalPolishConfig` for the post-ASR polish toggle.
|
||||
//
|
||||
// Why an enum in `Shared` rather than a `Bool`: the value must remain
|
||||
// serialisable into the App Group store (so the keyboard extension can
|
||||
// observe the selection) and exposed via `ProviderConfig` (UI binding).
|
||||
// Keeping the type stable even with a single case avoids a migration
|
||||
// the next time someone adds a non-cloud backend (e.g. whisper.cpp).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRBackend: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||
/// iOS 26 `SpeechAnalyzer` + `DictationTranscriber`. Always
|
||||
/// on-device, no asset download, ships with iOS. The only local
|
||||
/// backend in v0.2.0.
|
||||
case speechAnalyzer
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
/// Localisation key for the human label in the settings picker.
|
||||
public var labelKey: String {
|
||||
"asr.backend.speechAnalyzer.label"
|
||||
}
|
||||
|
||||
/// Localisation key for the one-line subtitle shown under the label.
|
||||
public var blurbKey: String {
|
||||
"asr.backend.speechAnalyzer.blurb"
|
||||
}
|
||||
|
||||
/// Whether this backend needs the user to download a model file
|
||||
/// before it can run. Always `false` for iOS-bundled speech.
|
||||
public var requiresModelDownload: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,9 @@ extension PersonalDictionary.Entry {
|
||||
/// learner. Users can re-classify later from Settings.
|
||||
public static func inferCategory(for term: String) -> Category {
|
||||
let hasUpper = term.contains(where: { $0.isUppercase })
|
||||
let hasDigit = term.contains(where: { $0.isNumber })
|
||||
let hasDigit = term.unicodeScalars.contains { scalar in
|
||||
CharacterSet.decimalDigits.contains(scalar) && scalar.isASCII
|
||||
}
|
||||
let hasLatin = term.unicodeScalars.contains { scalar in
|
||||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||||
}
|
||||
|
||||
@@ -15,144 +15,108 @@ import Combine
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
private enum Key {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
// Legacy: apiKey used to live in UserDefaults before the
|
||||
// migration. We still read it once (see init below) and then
|
||||
// delete the entry, but no other code path touches this key.
|
||||
static let apiKeyLegacy = "config.apiKey"
|
||||
static let model = "config.model"
|
||||
static let modeId = "config.modeId"
|
||||
static let localeId = "config.localeId"
|
||||
static let engineMode = "config.engineMode"
|
||||
static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||
static let onboardingPage = "config.onboardingPage"
|
||||
static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||
// Which on-device ASR engine to use when `engineMode == "local"`.
|
||||
// Persisted in the App Group so the keyboard can read the
|
||||
// selection even though it never instantiates the backend itself.
|
||||
static let localASRBackend = "config.localASRBackend"
|
||||
static let uiLanguage = "config.uiLanguage"
|
||||
// v0.2.0: optional cloud polish step after on-device ASR finishes
|
||||
// in the local engine. Default `false` — keeps the local engine
|
||||
// truly local unless the user explicitly opts in.
|
||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
||||
// v0.2.1: optional translation step after ASR. The
|
||||
// post-ASR transcript is routed through the same LLM with a
|
||||
// translate-and-polish prompt targeting `translationTargetLocaleId`.
|
||||
// Mutually exclusive with the local-only promise — see `TranslationPolicy`.
|
||||
//
|
||||
// v0.2.1 follow-up: `config.translationEnabled` was *removed*
|
||||
// as a persisted key — translation is now derived from
|
||||
// `translationTargetLocaleId` (== offLocaleId means "off"). The
|
||||
// store still tolerates legacy reads of the old key so users
|
||||
// who upgraded from a build that wrote it don't see a flash of
|
||||
// "on" state during init, but new writes never touch the key.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let handednessPreference = "config.handednessPreference"
|
||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
// v0.3.0: how aggressively the LLM should rewrite transcripts.
|
||||
static let polishIntensity = "config.polishIntensity"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
didSet {
|
||||
defaults.set(providerId, forKey: Key.providerId)
|
||||
// Keep API keys isolated per provider: switching provider in
|
||||
// Settings loads that provider's key instead of reusing the
|
||||
// previously selected vendor's key.
|
||||
guard !isApplyingConfiguration, providerId != configuration.providerId else { return }
|
||||
configuration.providerId = providerId
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = Keychain.apiKey(for: providerId) ?? ""
|
||||
apiKey = configuration.apiKey
|
||||
isSyncingProviderAPIKey = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var baseURL: String {
|
||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, baseURL != configuration.baseURL else { return }
|
||||
configuration.baseURL = baseURL
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var apiKey: String {
|
||||
didSet {
|
||||
// Skip the round-trip on init — we read from Keychain and
|
||||
// writing the same value back is wasteful.
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
||||
#endif
|
||||
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public var model: String {
|
||||
didSet { defaults.set(model, forKey: Key.model) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, model != configuration.model else { return }
|
||||
configuration.model = model
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var modeId: String {
|
||||
didSet { defaults.set(modeId, forKey: Key.modeId) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
|
||||
configuration.modeId = modeId
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var localeId: String {
|
||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
|
||||
configuration.localeId = localeId
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
defaults.set(engineMode, forKey: Key.engineMode)
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
configuration.engineMode = engineMode
|
||||
applyEngineModeSideEffects()
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
didSet {
|
||||
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
|
||||
guard !isApplyingConfiguration,
|
||||
hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return }
|
||||
configuration.hasCompletedOnboarding = hasCompletedOnboarding
|
||||
if hasCompletedOnboarding {
|
||||
configuration.onboardingPage = 0
|
||||
onboardingPage = 0
|
||||
}
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// Persisted onboarding step so returning from Settings does not reset progress.
|
||||
@Published public var onboardingPage: Int {
|
||||
didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, onboardingPage != configuration.onboardingPage else { return }
|
||||
configuration.onboardingPage = onboardingPage
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// User confirmed that Cloud polish sends transcripts to their configured third-party API.
|
||||
@Published public var hasAcknowledgedCloudSharing: Bool {
|
||||
didSet { defaults.set(hasAcknowledgedCloudSharing, forKey: Key.hasAcknowledgedCloudSharing) }
|
||||
}
|
||||
/// Which on-device ASR engine backs the "local" engine mode. Only
|
||||
/// consulted when `isLocalEngine == true`; the cloud engine always
|
||||
/// uses `SpeechAnalyzer`.
|
||||
@Published public var localASRBackend: LocalASRBackend {
|
||||
didSet { defaults.set(localASRBackend.rawValue, forKey: Key.localASRBackend) }
|
||||
}
|
||||
/// When `engineMode == "local"`, optionally route the ASR transcript
|
||||
/// through the user's configured LLM (DeepSeek by default) before
|
||||
/// inserting at the cursor. The polish step runs through the same
|
||||
/// `LLMClient` + `PolishingService` stack the cloud engine uses.
|
||||
///
|
||||
/// Defaults to `false` — the local engine is ASR-only out of the
|
||||
/// box. Users opt in from Settings when the iOS ASR output isn't
|
||||
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
|
||||
@Published public var localModeCloudPolishEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
hasAcknowledgedCloudSharing != configuration.hasAcknowledgedCloudSharing else { return }
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
||||
@Published public var uiLanguage: AppUILanguage {
|
||||
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, uiLanguage != configuration.uiLanguage else { return }
|
||||
configuration.uiLanguage = uiLanguage
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// v0.2.1: whether to translate the transcript into
|
||||
/// `translationTargetLocaleId` before insertion. **Derived** —
|
||||
/// translation is on iff the user has selected a target locale
|
||||
/// (i.e. the persisted id is anything other than
|
||||
/// `TranslationLanguageCatalog.offLocaleId`). Default off.
|
||||
///
|
||||
/// This used to be a stored `@Published var ... { didSet }` but the
|
||||
/// chip / picker now writes the locale directly; collapsing the
|
||||
/// pair into one field removes the "two writes out of sync" bug
|
||||
/// surface entirely.
|
||||
public var translationEnabled: Bool {
|
||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
configuration.translationEnabled
|
||||
}
|
||||
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||
/// translate-and-polish prompt should produce. Default `"off"` —
|
||||
@@ -161,31 +125,37 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// the user's choice without a host-app round-trip).
|
||||
@Published public var translationTargetLocaleId: String {
|
||||
didSet {
|
||||
defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
translationTargetLocaleId != configuration.translationTargetLocaleId else { return }
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
/// Which hand the user holds the phone with — mirrors to the keyboard
|
||||
/// extension so delete / return can swap on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference {
|
||||
didSet {
|
||||
defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
handednessPreference != configuration.handednessPreference else { return }
|
||||
configuration.handednessPreference = handednessPreference
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return }
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
configuration.isTranslationEffective
|
||||
}
|
||||
|
||||
/// Translation picker visibility — available on both engines.
|
||||
@@ -194,21 +164,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
||||
/// transcript. Default is `medium` (Typeless-equivalent).
|
||||
@Published public var polishIntensity: PolishIntensity {
|
||||
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, polishIntensity != configuration.polishIntensity else { return }
|
||||
configuration.polishIntensity = polishIntensity
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine (on-device ASR only) doesn't need an API key,
|
||||
// base URL, or model — the LLM round-trip is skipped entirely.
|
||||
// Treat it as always-configured so onboarding's "Next" button
|
||||
// enables the moment the user picks the local path, instead
|
||||
// of forcing them to fill in cloud fields they won't use.
|
||||
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
||||
// does not need a user API key. Cloud needs base URL, key, and model.
|
||||
if isLocalEngine { return true }
|
||||
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
/// On-device ASR only; no cloud API required.
|
||||
public var isLocalEngine: Bool { engineMode == "local" }
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
|
||||
/// Local engine always polishes via the built-in DeepSeek path.
|
||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||
@@ -217,83 +188,37 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public var localModeProviderId: String { "deepseek" }
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var configuration: AppGroupConfiguration
|
||||
private var isApplyingConfiguration = false
|
||||
private var isSyncingProviderAPIKey = false
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
||||
guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else {
|
||||
preconditionFailure(
|
||||
"ProviderConfig requires App Group or injected UserDefaults — " +
|
||||
"check AppGroup.isAvailable before constructing."
|
||||
)
|
||||
}
|
||||
self.defaults = resolvedDefaults
|
||||
let pid = resolvedDefaults.string(forKey: Key.providerId) ?? "openai"
|
||||
let preset = LLMProvider.provider(id: pid)
|
||||
self.providerId = pid
|
||||
self.baseURL = resolvedDefaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||
self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
|
||||
|
||||
// Resolve the API key with a one-shot migration from the legacy
|
||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||
// is empty in the suite and all subsequent reads go through the
|
||||
// Keychain.
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
|
||||
|
||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
||||
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
||||
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
||||
let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage)
|
||||
self.onboardingPage = savedPage > 0 ? savedPage : 0
|
||||
self.hasAcknowledgedCloudSharing = resolvedDefaults.bool(forKey: Key.hasAcknowledgedCloudSharing)
|
||||
// Tolerate missing / unknown raw values (e.g. an enum case that
|
||||
// was renamed in a later build) by falling back to the default
|
||||
// rather than crashing inside `RawRepresentable.init`.
|
||||
let rawBackend = resolvedDefaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
|
||||
self.localASRBackend = LocalASRBackend(rawValue: rawBackend) ?? .speechAnalyzer
|
||||
// v0.2.0: local-mode cloud polish toggle. Defaults off; users
|
||||
// opt in from Settings when iOS ASR is too lossy for their
|
||||
// environment. `object(forKey:) == nil` covers fresh installs
|
||||
// and upgrades from builds that never wrote the key.
|
||||
if resolvedDefaults.object(forKey: Key.localModeCloudPolishEnabled) == nil {
|
||||
self.localModeCloudPolishEnabled = false
|
||||
} else {
|
||||
self.localModeCloudPolishEnabled = resolvedDefaults.bool(forKey: Key.localModeCloudPolishEnabled)
|
||||
}
|
||||
self.uiLanguage = AppUILanguage.fromStored(
|
||||
resolvedDefaults.string(forKey: Key.uiLanguage)
|
||||
)
|
||||
// v0.2.1 follow-up: `translationEnabled` is now derived from
|
||||
// `translationTargetLocaleId` — no separate init read.
|
||||
// Default the locale id to `offLocaleId` so existing installs
|
||||
// that never picked a target language stay in the "off" state
|
||||
// (the previous build's default of `"en"` would silently turn
|
||||
// translation on for every upgraded user; off is the safe
|
||||
// conservative default that matches the picker / chip UX).
|
||||
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId)
|
||||
?? TranslationLanguageCatalog.offLocaleId
|
||||
self.handednessPreference = HandednessPreference.fromStored(
|
||||
resolvedDefaults.string(forKey: Key.handednessPreference)
|
||||
)
|
||||
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
|
||||
self.cursorDragNavigationEnabled = true
|
||||
} else {
|
||||
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
||||
}
|
||||
// v0.3.0: polish intensity. Default to `.medium` for new
|
||||
// installs; legacy `"off"` migrates to `.medium`.
|
||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
|
||||
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
} else {
|
||||
self.polishIntensity = .default
|
||||
}
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||
self.modeId = "polish"
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if self.engineMode == "cloud", self.providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
isApplyingConfiguration = true
|
||||
providerId = configuration.providerId
|
||||
baseURL = configuration.baseURL
|
||||
apiKey = configuration.apiKey
|
||||
model = configuration.model
|
||||
modeId = configuration.modeId
|
||||
localeId = configuration.localeId
|
||||
engineMode = configuration.engineMode
|
||||
hasCompletedOnboarding = configuration.hasCompletedOnboarding
|
||||
onboardingPage = configuration.onboardingPage
|
||||
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
|
||||
uiLanguage = configuration.uiLanguage
|
||||
translationTargetLocaleId = configuration.translationTargetLocaleId
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
polishIntensity = configuration.polishIntensity
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
@@ -304,29 +229,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time
|
||||
/// migration from the legacy UserDefaults slot.
|
||||
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
return stored
|
||||
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||
configuration.save(to: defaults)
|
||||
if postConfigChanged {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
// Migration path: old builds stored one global key under
|
||||
// Keychain account "current". Move it to the active provider.
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
public func apply(preset: LLMProvider) {
|
||||
isApplyingConfiguration = true
|
||||
providerId = preset.id
|
||||
if !preset.defaultBaseURL.isEmpty {
|
||||
baseURL = preset.defaultBaseURL
|
||||
@@ -334,15 +245,31 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if !preset.defaultModel.isEmpty {
|
||||
model = preset.defaultModel
|
||||
}
|
||||
configuration.providerId = providerId
|
||||
configuration.baseURL = baseURL
|
||||
configuration.model = model
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = configuration.apiKey
|
||||
isSyncingProviderAPIKey = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
providerId = "openai"
|
||||
isApplyingConfiguration = true
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
providerId = preset.id
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
handednessPreference = .left
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = preset.id
|
||||
configuration.baseURL = preset.defaultBaseURL
|
||||
configuration.model = preset.defaultModel
|
||||
configuration.handednessPreference = .left
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"bin_bytes" : 174285,
|
||||
"bin_file" : "OSGKeyboardCLM.bin",
|
||||
"export_seconds" : 0.029847979545593262,
|
||||
"generated_at" : "2026-07-05T11:34:09Z",
|
||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||
"locale" : "zh_CN",
|
||||
"phrase_count" : 11550,
|
||||
"sources" : {
|
||||
"ai_tech_seed" : 1259,
|
||||
"computer_terms" : 10300
|
||||
},
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
@@ -47,6 +47,9 @@ public protocol ASRService: Sendable {
|
||||
/// Clears cancellation / cached session state before a new utterance.
|
||||
func resetForNewUtterance()
|
||||
|
||||
/// Pre-load locale assets and analyzer format for lower first-chunk latency.
|
||||
func warmup(locale: Locale) async
|
||||
|
||||
/// Transcribe one PCM chunk (Flow pipelined path). Default wraps `transcribe(stream:)`.
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
}
|
||||
@@ -60,6 +63,8 @@ public enum ASRChunkResult: Sendable, Equatable {
|
||||
extension ASRService {
|
||||
public func resetForNewUtterance() {}
|
||||
|
||||
public func warmup(locale: Locale) async {}
|
||||
|
||||
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled { return .cancelled }
|
||||
@@ -116,27 +121,7 @@ public enum ASREvent: Sendable, Equatable {
|
||||
// MARK: - Factory
|
||||
|
||||
public enum ASRServiceFactory {
|
||||
/// Returns the on-device ASR backend. As of v0.2.0 the only
|
||||
/// supported `LocalASRBackend` is iOS 26 `SpeechAnalyzer` +
|
||||
/// `DictationTranscriber` (always on-device, no asset download),
|
||||
/// so the factory collapses to a single concrete type. We keep the
|
||||
/// `localBackend` parameter on the signature so the next non-iOS
|
||||
/// backend can slot in without touching every call site.
|
||||
///
|
||||
/// The cloud engine also routes through `SpeechAnalyzerASR`: the
|
||||
/// user expectation is that ASR is the local half of the pipeline
|
||||
/// regardless of where the LLM polish happens.
|
||||
public static func make(
|
||||
engineMode: String,
|
||||
localBackend: LocalASRBackend = .speechAnalyzer
|
||||
) -> ASRService {
|
||||
SpeechAnalyzerASR()
|
||||
}
|
||||
|
||||
/// Back-compat overload for callers that only ever want the
|
||||
/// SpeechAnalyzer path. The previous single-backend build used
|
||||
/// this signature; new code should pass the engine mode explicitly
|
||||
/// so any future non-iOS backend is honoured.
|
||||
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend.
|
||||
public static func make() -> ASRService {
|
||||
SpeechAnalyzerASR()
|
||||
}
|
||||
@@ -197,12 +182,52 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
private var chunkAnalyzerFormat: AVAudioFormat?
|
||||
|
||||
func resetForNewUtterance() {
|
||||
// Keep chunk format / asset cache warm across utterances in one Flow session.
|
||||
}
|
||||
|
||||
func invalidateChunkPreparationCache() {
|
||||
lock.withLock {
|
||||
chunkPreparedLocaleID = nil
|
||||
chunkAnalyzerFormat = nil
|
||||
}
|
||||
}
|
||||
|
||||
func warmup(locale: Locale) async {
|
||||
guard let resolvedLocale = await DictationTranscriber.supportedLocale(equivalentTo: locale) else {
|
||||
return
|
||||
}
|
||||
let localeID = resolvedLocale.identifier(.bcp47)
|
||||
let cachedLocaleID = lock.withLock { chunkPreparedLocaleID }
|
||||
if cachedLocaleID == localeID, lock.withLock({ chunkAnalyzerFormat != nil }) {
|
||||
return
|
||||
}
|
||||
|
||||
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||
locale: resolvedLocale
|
||||
)
|
||||
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||
locale: resolvedLocale,
|
||||
lmConfiguration: lmConfiguration
|
||||
)
|
||||
|
||||
do {
|
||||
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||
guard let format = await SpeechAnalyzer.bestAvailableAudioFormat(
|
||||
compatibleWith: [transcriber],
|
||||
considering: Self.captureFormat
|
||||
) else {
|
||||
return
|
||||
}
|
||||
lock.withLock {
|
||||
chunkPreparedLocaleID = localeID
|
||||
chunkAnalyzerFormat = format
|
||||
}
|
||||
Self.debug("warmup ready locale=\(localeID)")
|
||||
} catch {
|
||||
Self.debug("warmup failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled { return .cancelled }
|
||||
@@ -228,9 +253,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
throw ASRChunkError.localeUnsupported
|
||||
}
|
||||
let localeID = resolvedLocale.identifier(.bcp47)
|
||||
let transcriber = DictationTranscriber(
|
||||
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||
locale: resolvedLocale
|
||||
)
|
||||
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||
locale: resolvedLocale,
|
||||
preset: .progressiveLongDictation
|
||||
lmConfiguration: lmConfiguration
|
||||
)
|
||||
|
||||
let analyzerFormat: AVAudioFormat
|
||||
@@ -337,9 +365,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
}
|
||||
// Each pipelined chunk is ≤ 30 s; long dictation preset keeps a
|
||||
// single chunk coherent (Flow utterances run up to 3 min).
|
||||
let transcriber = DictationTranscriber(
|
||||
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||
locale: resolvedLocale
|
||||
)
|
||||
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||
locale: resolvedLocale,
|
||||
preset: .progressiveLongDictation
|
||||
lmConfiguration: lmConfiguration
|
||||
)
|
||||
do {
|
||||
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// AppGroupStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||
// instantiating an ObservableObject.
|
||||
// Thin read/write facade over `AppGroupConfiguration` for the keyboard
|
||||
// extension (no SwiftUI) and other non-ObservableObject call sites.
|
||||
//
|
||||
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
|
||||
// share access between the host app and the keyboard extension via a
|
||||
// shared keychain-access-group declared in both targets' entitlements.
|
||||
// `apiKey` is NOT stored in UserDefaults — see `Keychain.swift`.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -19,340 +16,150 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
self.defaults = defaults
|
||||
return
|
||||
}
|
||||
// Never hard-crash on implicit construction sites (e.g. default
|
||||
// service initializers). If App Group is unavailable, use .standard
|
||||
// so callers can still surface a user-facing setup error.
|
||||
self.defaults = AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
guard let available = AppGroup.defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
// Callers must check `AppGroup.isAvailable` before constructing.
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
}
|
||||
self.defaults = available
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
private var configuration: AppGroupConfiguration {
|
||||
AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
static let model = "config.model"
|
||||
static let modeId = "config.modeId"
|
||||
static let localeId = "config.localeId"
|
||||
static let engineMode = "config.engineMode"
|
||||
static let localASRBackend = "config.localASRBackend"
|
||||
static let uiLanguage = "config.uiLanguage"
|
||||
// v0.2.0: opt-in cloud polish step after local-mode ASR.
|
||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
||||
// v0.2.1 follow-up: `config.translationEnabled` was *removed* as a
|
||||
// persisted key — translation is derived from the target locale
|
||||
// id. New code should only write/read `translationTargetLocaleId`;
|
||||
// the `translationEnabled` Bool accessor below is kept as a
|
||||
// computed shim for source compatibility.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let handednessPreference = "config.handednessPreference"
|
||||
// Drag pads beside the mic move the caret like arrow keys.
|
||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
// v0.3.0: polish intensity (off / light / medium / heavy).
|
||||
static let polishIntensity = "config.polishIntensity"
|
||||
// v0.3.0: last app context detected by the keyboard extension.
|
||||
// Reused across calls within a 30-minute window so the LLM
|
||||
// prompt remains consistent during a single typing session.
|
||||
static let detectedAppContext = "config.detectedAppContext"
|
||||
static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
// v0.3.0: personal dictionary — JSON-encoded `PersonalDictionary`.
|
||||
static let personalDictionary = "config.personalDictionary.v1"
|
||||
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
|
||||
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
transform(&config)
|
||||
config.save(to: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
public var providerId: String {
|
||||
defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
}
|
||||
|
||||
public var baseURL: String {
|
||||
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
/// Returns "" when nothing is stored so the LLMClient can surface a
|
||||
/// `noAPIKey` error rather than firing off an obviously-bad request.
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
|
||||
}
|
||||
|
||||
public var modeId: String {
|
||||
defaults.string(forKey: Key.modeId) ?? "polish"
|
||||
}
|
||||
|
||||
public var localeId: String {
|
||||
defaults.string(forKey: Key.localeId) ?? "auto"
|
||||
}
|
||||
|
||||
/// "local" → on-device ASR only (raw transcript delivery).
|
||||
/// "cloud" → ASR + LLM polish (default behaviour).
|
||||
public var engineMode: String {
|
||||
defaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||
}
|
||||
|
||||
/// Which on-device ASR engine backs the "local" engine mode. Falls
|
||||
/// back to the iOS SpeechAnalyzer path so legacy installs (which
|
||||
/// never wrote this key) keep working.
|
||||
public var localASRBackend: LocalASRBackend {
|
||||
let raw = defaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
|
||||
return LocalASRBackend(rawValue: raw) ?? .speechAnalyzer
|
||||
}
|
||||
|
||||
/// v0.2.0: whether the local engine should route its transcript
|
||||
/// through the configured cloud LLM (DeepSeek by default) before
|
||||
/// insertion. Defaults to `false`; the keyboard extension reads
|
||||
/// this so Flow sessions honour the toggle.
|
||||
public var localModeCloudPolishEnabled: Bool {
|
||||
guard defaults.object(forKey: Key.localModeCloudPolishEnabled) != nil else {
|
||||
return false
|
||||
}
|
||||
return defaults.bool(forKey: Key.localModeCloudPolishEnabled)
|
||||
}
|
||||
|
||||
/// Host-app UI language override (`auto` / `en` / `zh-Hans`).
|
||||
public var uiLanguage: AppUILanguage {
|
||||
AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage))
|
||||
}
|
||||
|
||||
/// v0.2.1 follow-up: derived — translation is on iff a target locale
|
||||
/// has been selected. The `translationTargetLocaleId` getter below
|
||||
/// is the source of truth; this property exists for backwards
|
||||
/// compatibility with call sites that read `store.translationEnabled`.
|
||||
public var translationEnabled: Bool {
|
||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
/// v0.2.1: target locale id the translate-and-polish prompt should
|
||||
/// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off")
|
||||
/// when nothing is stored, matching the picker / chip UX where the
|
||||
/// user has to actively pick a language to turn translation on.
|
||||
public var translationTargetLocaleId: String {
|
||||
defaults.string(forKey: Key.translationTargetLocaleId)
|
||||
?? TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
/// Bottom-row key order on the keyboard extension.
|
||||
public var handednessPreference: HandednessPreference {
|
||||
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
/// Defaults to `true` for new installs.
|
||||
public var cursorDragNavigationEnabled: Bool {
|
||||
guard defaults.object(forKey: Key.cursorDragNavigationEnabled) != nil else {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.modeId)
|
||||
}
|
||||
|
||||
public func setLocaleId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.localeId)
|
||||
}
|
||||
|
||||
public func setEngineMode(_ mode: String) {
|
||||
defaults.set(mode, forKey: Key.engineMode)
|
||||
}
|
||||
|
||||
public func setLocalASRBackend(_ backend: LocalASRBackend) {
|
||||
defaults.set(backend.rawValue, forKey: Key.localASRBackend)
|
||||
}
|
||||
|
||||
public func setUILanguage(_ language: AppUILanguage) {
|
||||
defaults.set(language.rawValue, forKey: Key.uiLanguage)
|
||||
}
|
||||
|
||||
/// v0.2.1 follow-up: kept for source compatibility with callers that
|
||||
/// still pass a Bool (e.g. older tests, any leftover bridge code).
|
||||
/// `enabled == true` selects `defaultLocaleId` ("en") as a sensible
|
||||
/// on-ramp target; `enabled == false` resets to `offLocaleId`.
|
||||
/// The keyboard chip / pipeline now write the locale id directly
|
||||
/// via `setTranslationTargetLocaleId`, which is the preferred path.
|
||||
public func setTranslationEnabled(_ enabled: Bool) {
|
||||
defaults.set(
|
||||
enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId,
|
||||
forKey: Key.translationTargetLocaleId
|
||||
)
|
||||
}
|
||||
|
||||
/// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or
|
||||
/// `TranslationLanguageCatalog.offLocaleId`). The keyboard
|
||||
/// extension reads this on every `load()` and `refreshRuntimeFlags()`
|
||||
/// so the chip reflects the latest value without a host-app
|
||||
/// round-trip.
|
||||
public func setTranslationTargetLocaleId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setHandednessPreference(_ preference: HandednessPreference) {
|
||||
defaults.set(preference.rawValue, forKey: Key.handednessPreference)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Whether ASR output should be sent through the LLM polish step.
|
||||
/// Both engines always run polish after ASR completes (chunked
|
||||
/// pipeline stitches first). Ultra-short structure-free utterances
|
||||
/// may skip the LLM inside `PolishingService`.
|
||||
public var shouldRunCloudLLMStep: Bool { true }
|
||||
|
||||
/// Whether translate-and-polish should run (vs polish-only).
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
}
|
||||
public var providerId: String { configuration.providerId }
|
||||
public var baseURL: String { configuration.baseURL }
|
||||
public var apiKey: String { configuration.apiKey }
|
||||
public var model: String { configuration.model }
|
||||
public var modeId: String { configuration.modeId }
|
||||
public var localeId: String { configuration.localeId }
|
||||
public var engineMode: String { configuration.engineMode }
|
||||
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
|
||||
public var translationEnabled: Bool { configuration.translationEnabled }
|
||||
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
|
||||
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
|
||||
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
public var isTranslationChipVisible: Bool { true }
|
||||
|
||||
/// Cloud engine requires a provider-specific API key before the user
|
||||
/// can start voice input. Local engine uses the built-in DeepSeek path.
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
mutateConfiguration { $0.modeId = id }
|
||||
}
|
||||
|
||||
/// Polish vs translate-and-polish for the active pipeline.
|
||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||
isTranslationEffective
|
||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||
: .polish
|
||||
public func setLocaleId(_ id: String) {
|
||||
mutateConfiguration { $0.localeId = id }
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the
|
||||
/// user's configured provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
|
||||
// MARK: - Polish settings (v0.3.0+)
|
||||
|
||||
/// How aggressively the LLM should rewrite the ASR transcript.
|
||||
/// Defaults to `medium` for new installs.
|
||||
public var polishIntensity: PolishIntensity {
|
||||
guard let raw = defaults.string(forKey: Key.polishIntensity) else {
|
||||
return .default
|
||||
public func setEngineMode(_ mode: String) {
|
||||
mutateConfiguration { config in
|
||||
config.engineMode = mode
|
||||
if mode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
}
|
||||
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
public func setUILanguage(_ language: AppUILanguage) {
|
||||
mutateConfiguration { $0.uiLanguage = language }
|
||||
}
|
||||
|
||||
public func setTranslationEnabled(_ enabled: Bool) {
|
||||
setTranslationTargetLocaleId(
|
||||
enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
}
|
||||
|
||||
public func setTranslationTargetLocaleId(_ id: String) {
|
||||
mutateConfiguration { $0.translationTargetLocaleId = id }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setHandednessPreference(_ preference: HandednessPreference) {
|
||||
mutateConfiguration { $0.handednessPreference = preference }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setPolishIntensity(_ intensity: PolishIntensity) {
|
||||
defaults.set(intensity.rawValue, forKey: Key.polishIntensity)
|
||||
mutateConfiguration { $0.polishIntensity = intensity }
|
||||
}
|
||||
|
||||
// MARK: - Onboarding (v0.3.0+)
|
||||
//
|
||||
// Mirrored from `ProviderConfig` so the keyboard extension's
|
||||
// overlay can read / write the same source of truth without
|
||||
// instantiating the main-app config (which would drag in
|
||||
// SwiftUI / Combine and fight the keyboard's main-thread budget).
|
||||
|
||||
public var hasCompletedOnboarding: Bool {
|
||||
get { defaults.bool(forKey: "config.hasCompletedOnboarding") }
|
||||
set { defaults.set(newValue, forKey: "config.hasCompletedOnboarding") }
|
||||
get { configuration.hasCompletedOnboarding }
|
||||
set { setHasCompletedOnboarding(newValue) }
|
||||
}
|
||||
|
||||
public var onboardingPage: Int {
|
||||
get { defaults.integer(forKey: "config.onboardingPage") }
|
||||
set { defaults.set(newValue, forKey: "config.onboardingPage") }
|
||||
get { configuration.onboardingPage }
|
||||
set { setOnboardingPage(newValue) }
|
||||
}
|
||||
|
||||
public func setHasCompletedOnboarding(_ completed: Bool) {
|
||||
defaults.set(completed, forKey: "config.hasCompletedOnboarding")
|
||||
mutateConfiguration { config in
|
||||
config.hasCompletedOnboarding = completed
|
||||
if completed {
|
||||
config.onboardingPage = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setOnboardingPage(_ page: Int) {
|
||||
defaults.set(page, forKey: "config.onboardingPage")
|
||||
mutateConfiguration { $0.onboardingPage = page }
|
||||
}
|
||||
|
||||
// MARK: - Detected app context (v0.3.0+)
|
||||
// MARK: - Detected app context
|
||||
|
||||
/// Last app context the keyboard extension detected for this
|
||||
/// user, plus the timestamp it was observed. Callers should
|
||||
/// treat values older than 30 minutes as stale.
|
||||
public var detectedAppContext: (context: AppContext, observedAt: Date)? {
|
||||
guard let raw = defaults.string(forKey: Key.detectedAppContext),
|
||||
let value = AppContext(rawValue: raw)
|
||||
else { return nil }
|
||||
let timestamp = defaults.object(forKey: Key.detectedAppContextAt) as? Date ?? .distantPast
|
||||
return (value, timestamp)
|
||||
configuration.detectedAppContext(from: defaults)
|
||||
}
|
||||
|
||||
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
|
||||
defaults.set(context.rawValue, forKey: Key.detectedAppContext)
|
||||
defaults.set(date, forKey: Key.detectedAppContextAt)
|
||||
var config = configuration
|
||||
config.setDetectedAppContext(context, at: date, to: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Personal dictionary (v0.3.0+)
|
||||
// MARK: - Personal dictionary
|
||||
|
||||
/// Personal dictionary persisted in the App Group so both the
|
||||
/// main app's Settings UI and the keyboard extension's LLM call
|
||||
/// read the same source of truth. Returns an empty dictionary
|
||||
/// when nothing is stored (and when the stored JSON is corrupt —
|
||||
/// failing closed is safer than crashing the keyboard).
|
||||
public var personalDictionary: PersonalDictionary {
|
||||
get {
|
||||
guard let data = defaults.data(forKey: Key.personalDictionary) else {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||
dictionary.entries[index].source = .manual
|
||||
}
|
||||
dictionary.version += 1
|
||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||
defaults.set(migrated, forKey: Key.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
|
||||
#endif
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
set {
|
||||
setPersonalDictionary(newValue)
|
||||
}
|
||||
get { configuration.personalDictionary }
|
||||
set { setPersonalDictionary(newValue) }
|
||||
}
|
||||
|
||||
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(dictionary)
|
||||
defaults.set(data, forKey: Key.personalDictionary)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AppGroupStore] personalDictionary encode failed: \(error)")
|
||||
#endif
|
||||
}
|
||||
mutateConfiguration { $0.personalDictionary = dictionary }
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
configuration.makeClient()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// CustomLanguageModelManager.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Prepares the bundled SFCustomLanguageModelData asset on device and shares
|
||||
// the compiled LM + Vocab through the App Group container. Both the host app
|
||||
// and keyboard extension read the same prepared configuration for
|
||||
// DictationTranscriber content hints.
|
||||
|
||||
import Foundation
|
||||
import Speech
|
||||
import os
|
||||
|
||||
public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
|
||||
public static let shared = CustomLanguageModelManager()
|
||||
|
||||
public enum PrepareState: Equatable, Sendable {
|
||||
case idle
|
||||
case preparing
|
||||
case ready
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
struct BundledManifest: Decodable {
|
||||
let version: String
|
||||
let bin_bytes: Int
|
||||
let identifier: String
|
||||
}
|
||||
|
||||
private enum Storage {
|
||||
static let subdirectory = "CustomLanguageModel/v1"
|
||||
static let fingerprintKey = "customLM.preparedFingerprint"
|
||||
static let preparedAtKey = "customLM.preparedAt"
|
||||
static let lastFailureAtKey = "customLM.lastFailureAt"
|
||||
static let attemptCountKey = "customLM.attemptCount"
|
||||
static let maxRetryAttempts = 3
|
||||
/// Backoff after failure attempts 1, 2, and 3 (seconds).
|
||||
static let backoffIntervals: [TimeInterval] = [30, 120, 600]
|
||||
}
|
||||
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var cachedConfiguration: SFSpeechLanguageModel.Configuration?
|
||||
private var state: PrepareState = .idle
|
||||
private var prepareTask: Task<Void, Never>?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Returns a prepared configuration for Chinese locales when available.
|
||||
public func configurationForTranscription(locale: Locale) -> SFSpeechLanguageModel.Configuration? {
|
||||
guard Self.isChineseLocale(locale) else { return nil }
|
||||
return lock.withLock { () -> SFSpeechLanguageModel.Configuration? in
|
||||
if let cachedConfiguration {
|
||||
return cachedConfiguration
|
||||
}
|
||||
if let loaded = Self.loadCachedConfigurationFromDisk() {
|
||||
cachedConfiguration = loaded
|
||||
state = .ready
|
||||
return loaded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func currentState() -> PrepareState {
|
||||
lock.withLock { state }
|
||||
}
|
||||
|
||||
/// Fire-and-forget preparation for the host app. Safe to call repeatedly.
|
||||
/// Retries after exponential backoff when a prior attempt failed.
|
||||
public func prepareInBackgroundIfNeeded() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
|
||||
let shouldStart = lock.withLock { () -> Bool in
|
||||
if case .preparing = state { return false }
|
||||
if cachedConfiguration != nil { return false }
|
||||
if let loaded = Self.loadCachedConfigurationFromDisk() {
|
||||
cachedConfiguration = loaded
|
||||
state = .ready
|
||||
Self.clearRetryState()
|
||||
return false
|
||||
}
|
||||
if prepareTask != nil { return false }
|
||||
|
||||
if case .failed = state {
|
||||
guard Self.canRetryAfterFailure() else { return false }
|
||||
} else if !Self.canRetryAfterFailure() {
|
||||
return false
|
||||
}
|
||||
|
||||
state = .preparing
|
||||
return true
|
||||
}
|
||||
guard shouldStart else { return }
|
||||
|
||||
prepareTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
self.lock.withLock { self.prepareTask = nil }
|
||||
}
|
||||
do {
|
||||
_ = try await self.prepareIfNeeded()
|
||||
} catch {
|
||||
Self.recordFailure()
|
||||
self.lock.withLock {
|
||||
self.state = .failed(error.localizedDescription)
|
||||
}
|
||||
Self.log(
|
||||
"prepare failed (attempt \(Self.storedAttemptCount())): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares the bundled training asset into the App Group container.
|
||||
@discardableResult
|
||||
public func prepareIfNeeded() async throws -> SFSpeechLanguageModel.Configuration? {
|
||||
if let existing = configurationForTranscription(locale: Locale(identifier: "zh-Hans")) {
|
||||
lock.withLock { state = .ready }
|
||||
Self.clearRetryState()
|
||||
return existing
|
||||
}
|
||||
|
||||
guard Self.canRetryAfterFailure() else {
|
||||
throw PrepareError.retryBudgetExhausted
|
||||
}
|
||||
|
||||
guard let manifest = Self.bundledManifest() else {
|
||||
throw PrepareError.missingManifest
|
||||
}
|
||||
guard let assetURL = Self.bundledTrainingAssetURL() else {
|
||||
throw PrepareError.missingTrainingAsset
|
||||
}
|
||||
guard let preparedDir = Self.preparedDirectoryURL() else {
|
||||
throw PrepareError.missingAppGroupContainer
|
||||
}
|
||||
|
||||
let fingerprint = Self.fingerprint(for: manifest)
|
||||
if Self.storedFingerprint() == fingerprint,
|
||||
let cached = Self.loadCachedConfigurationFromDisk() {
|
||||
lock.withLock {
|
||||
cachedConfiguration = cached
|
||||
state = .ready
|
||||
}
|
||||
Self.clearRetryState()
|
||||
return cached
|
||||
}
|
||||
|
||||
lock.withLock { state = .preparing }
|
||||
|
||||
let languageModelURL = preparedDir.appendingPathComponent("LM")
|
||||
let vocabularyURL = preparedDir.appendingPathComponent("Vocab")
|
||||
try Self.removeItemIfExists(at: languageModelURL)
|
||||
try Self.removeItemIfExists(at: vocabularyURL)
|
||||
|
||||
let configuration = SFSpeechLanguageModel.Configuration(
|
||||
languageModel: languageModelURL,
|
||||
vocabulary: vocabularyURL
|
||||
)
|
||||
|
||||
Self.log("preparing custom LM (\(manifest.bin_bytes) byte asset)…")
|
||||
try await Self.prepareLanguageModel(assetURL: assetURL, configuration: configuration)
|
||||
|
||||
guard FileManager.default.fileExists(atPath: languageModelURL.path),
|
||||
FileManager.default.fileExists(atPath: vocabularyURL.path) else {
|
||||
throw PrepareError.missingPreparedArtifacts
|
||||
}
|
||||
|
||||
AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||
AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||
Self.clearRetryState()
|
||||
|
||||
lock.withLock {
|
||||
cachedConfiguration = configuration
|
||||
state = .ready
|
||||
}
|
||||
|
||||
Self.log("custom LM ready at \(preparedDir.path)")
|
||||
return configuration
|
||||
}
|
||||
|
||||
// MARK: - DictationTranscriber factory
|
||||
|
||||
public static func makeDictationTranscriber(
|
||||
locale: Locale,
|
||||
lmConfiguration: SFSpeechLanguageModel.Configuration?
|
||||
) -> DictationTranscriber {
|
||||
let preset = DictationTranscriber.Preset.progressiveLongDictation
|
||||
guard let lmConfiguration, isChineseLocale(locale) else {
|
||||
return DictationTranscriber(locale: locale, preset: preset)
|
||||
}
|
||||
|
||||
let contentHints = preset.contentHints.union([
|
||||
.customizedLanguage(modelConfiguration: lmConfiguration),
|
||||
])
|
||||
return DictationTranscriber(
|
||||
locale: locale,
|
||||
contentHints: contentHints,
|
||||
transcriptionOptions: preset.transcriptionOptions,
|
||||
reportingOptions: preset.reportingOptions,
|
||||
attributeOptions: preset.attributeOptions
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Bundle / disk helpers
|
||||
|
||||
private static var resourceBundle: Bundle {
|
||||
Bundle(for: CustomLanguageModelManager.self)
|
||||
}
|
||||
|
||||
static func bundledTrainingAssetURL() -> URL? {
|
||||
if let url = resourceBundle.url(
|
||||
forResource: "OSGKeyboardCLM",
|
||||
withExtension: "bin",
|
||||
subdirectory: Storage.subdirectory
|
||||
) {
|
||||
return url
|
||||
}
|
||||
return resourceBundle.url(forResource: "OSGKeyboardCLM", withExtension: "bin")
|
||||
}
|
||||
|
||||
static func bundledManifest() -> BundledManifest? {
|
||||
let manifestURL =
|
||||
resourceBundle.url(
|
||||
forResource: "compiled-manifest",
|
||||
withExtension: "json",
|
||||
subdirectory: Storage.subdirectory
|
||||
)
|
||||
?? resourceBundle.url(forResource: "compiled-manifest", withExtension: "json")
|
||||
guard let manifestURL,
|
||||
let data = try? Data(contentsOf: manifestURL),
|
||||
let manifest = try? JSONDecoder().decode(BundledManifest.self, from: data)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
static func preparedDirectoryURL() -> URL? {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? {
|
||||
guard let manifest = bundledManifest(),
|
||||
storedFingerprint() == fingerprint(for: manifest),
|
||||
let preparedDir = preparedDirectoryURL()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let languageModelURL = preparedDir.appendingPathComponent("LM")
|
||||
let vocabularyURL = preparedDir.appendingPathComponent("Vocab")
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: languageModelURL.path),
|
||||
fm.fileExists(atPath: vocabularyURL.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return SFSpeechLanguageModel.Configuration(
|
||||
languageModel: languageModelURL,
|
||||
vocabulary: vocabularyURL
|
||||
)
|
||||
}
|
||||
|
||||
static func isChineseLocale(_ locale: Locale) -> Bool {
|
||||
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
|
||||
}
|
||||
|
||||
private static func fingerprint(for manifest: BundledManifest) -> String {
|
||||
"\(manifest.identifier)|\(manifest.version)|\(manifest.bin_bytes)"
|
||||
}
|
||||
|
||||
private static func storedFingerprint() -> String? {
|
||||
AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey)
|
||||
}
|
||||
|
||||
private static func removeItemIfExists(at url: URL) throws {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
try fm.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private static func prepareLanguageModel(
|
||||
assetURL: URL,
|
||||
configuration: SFSpeechLanguageModel.Configuration
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
SFSpeechLanguageModel.prepareCustomLanguageModel(
|
||||
for: assetURL,
|
||||
configuration: configuration
|
||||
) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retry / backoff
|
||||
|
||||
private static func storedAttemptCount() -> Int {
|
||||
AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0
|
||||
}
|
||||
|
||||
private static func storedLastFailureAt() -> TimeInterval? {
|
||||
let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0
|
||||
return value > 0 ? value : nil
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
let nextAttempt = storedAttemptCount() + 1
|
||||
defaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
private static func clearRetryState() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
defaults.removeObject(forKey: Storage.attemptCountKey)
|
||||
defaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
/// Returns false when retry budget is exhausted or backoff has not elapsed.
|
||||
private static func canRetryAfterFailure() -> Bool {
|
||||
let attempts = storedAttemptCount()
|
||||
guard attempts > 0 else { return true }
|
||||
guard attempts <= Storage.maxRetryAttempts else { return false }
|
||||
|
||||
guard let lastFailureAt = storedLastFailureAt() else { return true }
|
||||
let backoffIndex = min(attempts - 1, Storage.backoffIntervals.count - 1)
|
||||
let requiredDelay = Storage.backoffIntervals[backoffIndex]
|
||||
let elapsed = Date().timeIntervalSince1970 - lastFailureAt
|
||||
return elapsed >= requiredDelay
|
||||
}
|
||||
|
||||
private static func log(_ message: String) {
|
||||
OSGLog.clm.info("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
enum PrepareError: LocalizedError {
|
||||
case missingManifest
|
||||
case missingTrainingAsset
|
||||
case missingAppGroupContainer
|
||||
case missingPreparedArtifacts
|
||||
case retryBudgetExhausted
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingManifest:
|
||||
return "Missing bundled custom language model manifest."
|
||||
case .missingTrainingAsset:
|
||||
return "Missing bundled custom language model training asset."
|
||||
case .missingAppGroupContainer:
|
||||
return "App Group container unavailable for custom language model preparation."
|
||||
case .missingPreparedArtifacts:
|
||||
return "Custom language model preparation did not produce LM/Vocab artifacts."
|
||||
case .retryBudgetExhausted:
|
||||
return "Custom language model preparation retry budget exhausted."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// DictationBridge.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Lightweight App Group bridge for host-app dictation handoff:
|
||||
// keyboard extension -> open host app for recording
|
||||
// host app -> writes final transcript
|
||||
// keyboard extension -> consumes pending transcript and inserts text
|
||||
//
|
||||
// STATUS (v0.1.2): Retained. Consumed by `KeyboardViewController` for
|
||||
// the "one-shot" host-app dictation path (where the keyboard extension
|
||||
// launches the host app, the user records there, and the resulting
|
||||
// text is consumed back by the extension). The *continuous* path goes
|
||||
// through `FlowSessionBridge` + `FlowSessionManager` instead.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum DictationBridge {
|
||||
public enum Status: String, Sendable, Equatable {
|
||||
case idle
|
||||
case requested
|
||||
case recording
|
||||
case transcribing
|
||||
case done
|
||||
case cancelled
|
||||
case error
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let pendingText = "dictation.pendingText"
|
||||
static let polishWarning = "dictation.polishWarning"
|
||||
static let updatedAt = "dictation.updatedAt"
|
||||
static let status = "dictation.status"
|
||||
static let statusUpdatedAt = "dictation.statusUpdatedAt"
|
||||
static let statusMessage = "dictation.statusMessage"
|
||||
}
|
||||
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
if let defaults {
|
||||
return defaults
|
||||
}
|
||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
}
|
||||
|
||||
public static func setStatus(
|
||||
_ status: Status,
|
||||
message: String? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(status.rawValue, forKey: Key.status)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.statusUpdatedAt)
|
||||
if let message, !message.isEmpty {
|
||||
store.set(message, forKey: Key.statusMessage)
|
||||
} else {
|
||||
store.removeObject(forKey: Key.statusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
public static func currentStatus(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> (status: Status, message: String?, updatedAt: TimeInterval) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let raw = store.string(forKey: Key.status) ?? Status.idle.rawValue
|
||||
let status = Status(rawValue: raw) ?? .idle
|
||||
let message = store.string(forKey: Key.statusMessage)
|
||||
let updatedAt = store.double(forKey: Key.statusUpdatedAt)
|
||||
return (status, message, updatedAt)
|
||||
}
|
||||
|
||||
public static func markRequested(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
setStatus(.requested, defaults: store)
|
||||
}
|
||||
|
||||
/// Store a transcript for the keyboard extension to consume.
|
||||
public static func storePendingTranscript(
|
||||
_ text: String,
|
||||
polishWarning: String? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(trimmed, forKey: Key.pendingText)
|
||||
store.set(Date().timeIntervalSince1970, forKey: Key.updatedAt)
|
||||
if let polishWarning, !polishWarning.isEmpty {
|
||||
store.set(polishWarning, forKey: Key.polishWarning)
|
||||
} else {
|
||||
store.removeObject(forKey: Key.polishWarning)
|
||||
}
|
||||
setStatus(.done, defaults: store)
|
||||
}
|
||||
|
||||
/// Returns and clears the pending transcript if present.
|
||||
public static func consumePendingTranscript(
|
||||
maxAge: TimeInterval = 180,
|
||||
defaults: UserDefaults? = nil
|
||||
) -> String? {
|
||||
consumePendingDelivery(maxAge: maxAge, defaults: defaults)?.text
|
||||
}
|
||||
|
||||
/// Returns and clears the pending delivery (text + optional polish
|
||||
/// warning) if present.
|
||||
public static func consumePendingDelivery(
|
||||
maxAge: TimeInterval = 180,
|
||||
defaults: UserDefaults? = nil
|
||||
) -> TranscriptionDelivery? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard let text = store.string(forKey: Key.pendingText) else {
|
||||
return nil
|
||||
}
|
||||
if maxAge > 0 {
|
||||
let ts = store.double(forKey: Key.updatedAt)
|
||||
if ts > 0, Date().timeIntervalSince1970 - ts > maxAge {
|
||||
clear(defaults: store)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let warning = store.string(forKey: Key.polishWarning)
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
store.removeObject(forKey: Key.polishWarning)
|
||||
store.removeObject(forKey: Key.updatedAt)
|
||||
setStatus(.idle, defaults: store)
|
||||
return TranscriptionDelivery(text: text, polishWarning: warning)
|
||||
}
|
||||
|
||||
public static func clear(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: Key.pendingText)
|
||||
store.removeObject(forKey: Key.polishWarning)
|
||||
store.removeObject(forKey: Key.updatedAt)
|
||||
setStatus(.idle, defaults: store)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// FlowAppLifecycle.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tracks whether the host app process is in the foreground.
|
||||
// Retained for any future GPU-backed paths; CoreML ASR does not require it.
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class FlowAppLifecycle: @unchecked Sendable {
|
||||
|
||||
public static let shared = FlowAppLifecycle()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var isForeground = true
|
||||
|
||||
private init() {}
|
||||
|
||||
/// `true` when the host app scene is active (`.active`).
|
||||
public var allowsGPUInference: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return isForeground
|
||||
}
|
||||
|
||||
public func setForeground(_ foreground: Bool) {
|
||||
lock.lock()
|
||||
isForeground = foreground
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Blocks until foreground or cancellation.
|
||||
public func waitUntilForeground() async -> Bool {
|
||||
while !allowsGPUInference {
|
||||
if Task.isCancelled { return false }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,31 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
||||
// signals; host app writes transcription results. Legacy one-shot
|
||||
// dictation handoff remains in `DictationBridge`.
|
||||
// signals; host app writes transcription results.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowTranscriptionError: Equatable, Sendable {
|
||||
public let message: String
|
||||
public let kind: FlowSessionKeys.TranscriptionErrorKind
|
||||
|
||||
public init(message: String, kind: FlowSessionKeys.TranscriptionErrorKind) {
|
||||
self.message = message
|
||||
self.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
public enum FlowSessionBridge {
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
if let defaults { return defaults }
|
||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
||||
guard let available = AppGroup.defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
/// Force cross-process visibility. Must only be called on the main thread.
|
||||
@@ -70,7 +86,6 @@ public enum FlowSessionBridge {
|
||||
/// background while the continuous audio session is frozen.
|
||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
||||
|
||||
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
@@ -81,7 +96,6 @@ public enum FlowSessionBridge {
|
||||
/// actively processing). Used for auto-start heuristics, not gating record.
|
||||
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard isSessionActive(defaults: store) else { return false }
|
||||
|
||||
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
@@ -144,6 +158,7 @@ public enum FlowSessionBridge {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||
if let polishWarning, !polishWarning.isEmpty {
|
||||
store.set(polishWarning, forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||
} else {
|
||||
@@ -151,16 +166,46 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
setRecordingState(.idle, defaults: store)
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
/// Host app: publish pipelined ASR partial while recording or finalizing.
|
||||
public static func storeTranscriptionPartial(
|
||||
_ text: String,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let store = resolvedDefaults(defaults)
|
||||
if trimmed.isEmpty {
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||
} else {
|
||||
store.set(trimmed, forKey: FlowSessionKeys.transcriptionPartial)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
/// Keyboard: read the latest partial without clearing it.
|
||||
public static func transcriptionPartial(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard let text = store.string(forKey: FlowSessionKeys.transcriptionPartial),
|
||||
!text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
public static func storeTranscriptionError(
|
||||
_ message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
||||
store.set(kind.rawValue, forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
/// Returns and clears a pending transcription result, if any.
|
||||
@@ -174,7 +219,6 @@ public enum FlowSessionBridge {
|
||||
defaults: UserDefaults? = nil
|
||||
) -> TranscriptionDelivery? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
@@ -186,20 +230,21 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
|
||||
/// Returns and clears a pending transcription error, if any.
|
||||
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> String? {
|
||||
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> FlowTranscriptionError? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let kindRaw = store.string(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
let kind = FlowSessionKeys.TranscriptionErrorKind(rawValue: kindRaw ?? "") ?? .generic
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
flush(store)
|
||||
return message
|
||||
return FlowTranscriptionError(message: message, kind: kind)
|
||||
}
|
||||
|
||||
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
||||
return levels.map { Float($0) }
|
||||
}
|
||||
@@ -240,7 +285,9 @@ public enum FlowSessionBridge {
|
||||
|
||||
private static func clearTranscription(defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import Foundation
|
||||
|
||||
public enum FlowSessionDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
||||
/// Posted when the host app writes a transcription result or error.
|
||||
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
|
||||
|
||||
public static func postSessionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
@@ -18,6 +20,16 @@ public enum FlowSessionDarwin {
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postTranscriptionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(transcriptionNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
|
||||
@@ -13,9 +13,13 @@ public enum FlowSessionKeys {
|
||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||
public static let transcriptionResult = "flow.transcriptionResult"
|
||||
/// Live pipelined ASR partial for the keyboard transcript line.
|
||||
public static let transcriptionPartial = "flow.transcriptionPartial"
|
||||
/// Soft warning when polish failed but raw transcript was delivered.
|
||||
public static let transcriptionPolishWarning = "flow.transcriptionPolishWarning"
|
||||
public static let transcriptionError = "flow.transcriptionError"
|
||||
/// Structured kind paired with `transcriptionError` for keyboard UI.
|
||||
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
|
||||
/// Heartbeat older than this while the host is foreground → likely killed.
|
||||
@@ -36,15 +40,7 @@ public enum FlowSessionKeys {
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
||||
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
||||
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
||||
///
|
||||
/// As of v0.2.0 the local engine uses iOS `SpeechAnalyzer` only, so the
|
||||
/// previous Qwen3-specific timeout (240 s) collapses into the shared
|
||||
/// local path. We keep `localASRBackend` on the signature for symmetry
|
||||
/// with other shared helpers.
|
||||
public static func keyboardResultTimeout(
|
||||
engineMode: String,
|
||||
localASRBackend: LocalASRBackend
|
||||
) -> TimeInterval {
|
||||
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
|
||||
if engineMode == "local" {
|
||||
return 180
|
||||
}
|
||||
@@ -58,4 +54,13 @@ public enum FlowSessionKeys {
|
||||
case processing
|
||||
case aborted
|
||||
}
|
||||
|
||||
/// Structured host → keyboard transcription failure kind.
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable {
|
||||
case noSpeech
|
||||
case recognitionInterrupted
|
||||
case audioUnavailable
|
||||
case asrFailed
|
||||
case generic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,26 +35,37 @@ public final class KeyboardState: ObservableObject {
|
||||
case asr(String)
|
||||
case llm(LLMError)
|
||||
case appGroupUnavailable
|
||||
/// Keyboard extension lacks Full Access for host-app jumps.
|
||||
case fullAccessRequired
|
||||
/// Auto-jump to the host app failed; user must open it manually.
|
||||
case manualOpenRequired
|
||||
/// Host delivered raw transcript; polish step failed or was skipped.
|
||||
case polishDegraded(String)
|
||||
/// Host ASR finished with no usable speech.
|
||||
case noSpeechDetected
|
||||
/// Host ASR was interrupted before a final transcript arrived.
|
||||
case recognitionInterrupted
|
||||
/// Host could not start background audio capture.
|
||||
case hostAudioUnavailable
|
||||
/// Host ASR or pipeline failed with a user-facing message.
|
||||
case hostTranscriptionFailed(String)
|
||||
/// Flow result did not arrive before the keyboard watchdog expired.
|
||||
case flowResultTimeout
|
||||
/// Host Flow session ended while the keyboard was idle.
|
||||
case flowSessionExpired
|
||||
case unknown(String)
|
||||
}
|
||||
|
||||
public enum Reason: Equatable { case mic, speech }
|
||||
}
|
||||
|
||||
/// Voice input always runs through polish; legacy off/transcribe modes removed.
|
||||
public enum InputMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
case transcribe
|
||||
case polish
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .off: return "mode.off"
|
||||
case .transcribe: return "mode.transcribe"
|
||||
case .polish: return "mode.polish"
|
||||
}
|
||||
}
|
||||
public var labelKey: String { "mode.polish" }
|
||||
}
|
||||
|
||||
@Published public var phase: Phase = .idle
|
||||
@@ -78,20 +89,6 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||
@Published public var engineMode: String = "cloud"
|
||||
/// Which on-device ASR engine to use when `engineMode == "local"`.
|
||||
/// Mirrored from `ProviderConfig.localASRBackend` for UI display
|
||||
/// and for `state` consumers that want a single source of truth.
|
||||
@Published public var localASRBackend: LocalASRBackend = .speechAnalyzer
|
||||
/// v0.2.0: kept for source compatibility with the previous Qwen3
|
||||
/// CoreML local engine. Always `true` now — iOS `SpeechAnalyzer`
|
||||
/// ships with iOS 26 and has no per-user weights to download or
|
||||
/// preload. Existing read sites will see `true` and behave the
|
||||
/// same as the "stack ready" branch did.
|
||||
@Published public var localModelsReady: Bool = true
|
||||
/// v0.2.0: kept for source compatibility with the previous Qwen3
|
||||
/// CoreML local engine. Always `false` now — there are no weights
|
||||
/// for the host app to preload.
|
||||
@Published public var localModelsLoaded: Bool = false
|
||||
/// v0.2.1 follow-up: derived — translation is on iff a target
|
||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||
/// so the chip / pipeline read the same source of truth).
|
||||
@@ -103,9 +100,6 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// v0.2.0: mirrored from App Group — kept for source compatibility.
|
||||
/// Local engine always runs built-in polish; the flag is ignored.
|
||||
@Published public var localModeCloudPolishEnabled: Bool = true
|
||||
/// Mirrored from App Group — swaps delete / return on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference = .left
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@@ -159,7 +153,6 @@ public final class KeyboardState: ObservableObject {
|
||||
public var setMode: (InputMode) -> Void = { _ in }
|
||||
public var setLocale: (String) -> Void = { _ in }
|
||||
public var setEngineMode: (String) -> Void = { _ in }
|
||||
public var setLocalASRBackend: (LocalASRBackend) -> Void = { _ in }
|
||||
/// v0.2.1 follow-up: only the locale picker remains — `enabled`
|
||||
/// is derived from the locale id, so there's no separate toggle to
|
||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||
@@ -209,4 +202,20 @@ public final class KeyboardState: ObservableObject {
|
||||
return s
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension KeyboardState.Phase.ErrorKind {
|
||||
/// Maps a host-app Flow transcription failure into a keyboard error kind.
|
||||
public static func fromFlowTranscription(_ error: FlowTranscriptionError) -> Self {
|
||||
switch error.kind {
|
||||
case .noSpeech:
|
||||
return .noSpeechDetected
|
||||
case .recognitionInterrupted:
|
||||
return .recognitionInterrupted
|
||||
case .audioUnavailable:
|
||||
return .hostAudioUnavailable
|
||||
case .asrFailed, .generic:
|
||||
return .hostTranscriptionFailed(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
// This class is still imported by:
|
||||
// - `OSGKeyboard/Views/PreviewASRController.swift` (typealias)
|
||||
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (in-app preview)
|
||||
// - `OSGKeyboard/Views/DictationCaptureView.swift` (host-app fallback)
|
||||
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (host-app ASR preview)
|
||||
// - `OSGKeyboardTests/PreviewASRControllerStateTests.swift`
|
||||
//
|
||||
// Do NOT remove without updating those call sites. The earlier
|
||||
@@ -104,14 +104,7 @@ public final class LiveDictationController: ObservableObject {
|
||||
private var didInstallTap = false
|
||||
|
||||
public init(asr: ASRService? = nil) {
|
||||
// Resolve through the factory so the user's `LocalASRBackend`
|
||||
// selection is honoured. Tests can pass a stub `asr` directly
|
||||
// to bypass the factory and exercise the controller in
|
||||
// isolation.
|
||||
self.asr = asr ?? ASRServiceFactory.make(
|
||||
engineMode: ProviderConfig.shared.engineMode,
|
||||
localBackend: ProviderConfig.shared.localASRBackend
|
||||
)
|
||||
self.asr = asr ?? ASRServiceFactory.make()
|
||||
}
|
||||
|
||||
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// OSGLog.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Unified os.Logger categories for cross-target diagnostics. Filter in
|
||||
// Console.app with subsystem `com.osgkeyboard.ios`.
|
||||
|
||||
import os
|
||||
|
||||
public enum OSGLog {
|
||||
private static let subsystem = "com.osgkeyboard.ios"
|
||||
|
||||
public static let flow = Logger(subsystem: subsystem, category: "flow")
|
||||
public static let clm = Logger(subsystem: subsystem, category: "clm")
|
||||
public static let config = Logger(subsystem: subsystem, category: "config")
|
||||
public static let asr = Logger(subsystem: subsystem, category: "asr")
|
||||
public static let keyboardExt = Logger(subsystem: subsystem, category: "keyboardExt")
|
||||
}
|
||||
@@ -17,7 +17,8 @@ public enum UtteranceStreamChunker {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
var buffer: [Float] = []
|
||||
buffer.reserveCapacity(config.maxChunkSamples + config.pauseExtensionSamples)
|
||||
let initialCapacity = config.maxChunkSamples(forChunkIndex: 0) + config.pauseExtensionSamples
|
||||
buffer.reserveCapacity(initialCapacity)
|
||||
var chunkIndex = 0
|
||||
|
||||
func emit(upTo splitEnd: Int, isLast: Bool) {
|
||||
@@ -40,8 +41,12 @@ public enum UtteranceStreamChunker {
|
||||
guard !snap.samples.isEmpty else { continue }
|
||||
buffer.append(contentsOf: snap.samples)
|
||||
|
||||
while buffer.count >= config.maxChunkSamples {
|
||||
let split = pauseAwareSplitIndex(in: buffer, config: config)
|
||||
while buffer.count >= config.maxChunkSamples(forChunkIndex: chunkIndex) {
|
||||
let split = pauseAwareSplitIndex(
|
||||
in: buffer,
|
||||
config: config,
|
||||
chunkIndex: chunkIndex
|
||||
)
|
||||
emit(upTo: split, isLast: false)
|
||||
}
|
||||
}
|
||||
@@ -66,9 +71,10 @@ public enum UtteranceStreamChunker {
|
||||
/// Pick a split index at or after `maxChunkSamples`, preferring a pause.
|
||||
static func pauseAwareSplitIndex(
|
||||
in buffer: [Float],
|
||||
config: FlowUtteranceChunkConfig
|
||||
config: FlowUtteranceChunkConfig,
|
||||
chunkIndex: Int = 1
|
||||
) -> Int {
|
||||
let minSplit = config.maxChunkSamples
|
||||
let minSplit = config.maxChunkSamples(forChunkIndex: chunkIndex)
|
||||
guard buffer.count >= minSplit else { return buffer.count }
|
||||
|
||||
let searchEnd = min(buffer.count, minSplit + config.pauseExtensionSamples)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
"provider.zhipu" = "Zhipu GLM";
|
||||
"provider.moonshot" = "Moonshot";
|
||||
"provider.mimo" = "Xiaomi MiMo";
|
||||
"provider.custom" = "Custom";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -79,3 +80,10 @@
|
||||
"dict.source.history" = "Auto-learned";
|
||||
"dict.source.contacts" = "From Contacts";
|
||||
"dict.source.recentEdit" = "From recent edit";
|
||||
|
||||
/* Keyboard UI (shared between extension + preview) */
|
||||
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||
"keyboard.translation.chip" = "Translate";
|
||||
"keyboard.translation.offMenu" = "Don't translate";
|
||||
"keyboard.translation.a11y" = "Translation";
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"provider.qwen" = "通义千问";
|
||||
"provider.zhipu" = "智谱 GLM";
|
||||
"provider.moonshot" = "月之暗面";
|
||||
"provider.mimo" = "小米 MiMo";
|
||||
"provider.custom" = "自定义";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -79,3 +80,10 @@
|
||||
"dict.source.history" = "自动学习";
|
||||
"dict.source.contacts" = "来自通讯录";
|
||||
"dict.source.recentEdit" = "来自最近编辑";
|
||||
|
||||
/* 键盘 UI(扩展与预览共用) */
|
||||
"keyboard.tapToTalkA11y" = "点击说话";
|
||||
"keyboard.translation.chip" = "翻译";
|
||||
"keyboard.translation.offMenu" = "不翻译";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
|
||||
|
||||
Reference in New Issue
Block a user