import Foundation import Speech /// The user's transcription-language configuration and the Speech asset state /// behind it. `@Observable @MainActor` so the Settings UI binds to it directly. /// /// There is no automatic language detection in `SpeechAnalyzer`, so the user /// enables a small set of languages; a new voice note is sampled in each and the /// highest-confidence one wins (see `TranscriptionService`). Enabling a language /// *reserves* it with `AssetInventory` — which is both what keeps its model from /// being purged and what the system caps at `AssetInventory.maximumReservedLocales` /// — so "enabled" and "reserved" are kept in lock-step and the UI enforces the cap. /// /// The supported set is the *union* of two on-device SpeechAnalyzer modules' /// coverage: `SpeechTranscriber` (30 locales) and `DictationTranscriber` (54, /// including ru-RU, uk-UA, pl-PL … that `SpeechTranscriber` lacks). Each locale /// is routed to whichever engine supports it, preferring `SpeechTranscriber` /// when both do (see `engine(for:)`). Reservation is locale-based and engine- /// agnostic; only asset queries and analyzer construction pick a module type. @Observable @MainActor final class TranscriptionSettings { /// Which SpeechAnalyzer module transcribes a given locale. `SpeechTranscriber` /// is preferred where available (it reports per-run confidence the sampling /// pass relies on); `DictationTranscriber` covers the locales it doesn't. enum EngineKind: Equatable { case speech case dictation } private static let enabledKey = "Notes.enabledTranscriptionLocales" private static let seededKey = "Notes.transcriptionLocalesSeeded" /// Per-install id of the transcription owner, reused from `InstallIdentity` /// — a voice note stamps this into `AudioInfo.transcriberDeviceID` so only the /// recording device transcribes it. static var installID: String { InstallIdentity.installID } /// Canonical BCP-47 ids of the enabled languages, persisted in UserDefaults. private(set) var enabledLocaleIDs: [String] /// Every locale either engine can transcribe — the union of `SpeechTranscriber` /// and `DictationTranscriber` coverage, de-duplicated by canonical id and /// loaded once by `prepare()`. Empty until then. private(set) var supportedLocales: [Locale] = [] /// Canonical ids `SpeechTranscriber` supports, for engine resolution. A locale /// in this set is routed to `.speech`; anything else supported falls to /// `.dictation`. Populated by `prepare()`. private var speechLocaleIDs: Set = [] /// Canonical ids `DictationTranscriber` supports, for engine resolution and /// for distinguishing a genuinely unsupported locale from a dictation-only one. private var dictationLocaleIDs: Set = [] /// Runtime cap on how many languages can be enabled at once — read from the /// system, not hard-coded, because it can change. private(set) var maximumReserved = 0 /// Per-language asset state, keyed by canonical BCP-47 id, for the row UI. private(set) var assetStates: [String: AssetState] = [:] /// Where a language's on-device model sits. enum AssetState: Equatable { case unsupported case notInstalled /// Downloading; the fraction is nil until progress is known. case downloading(Double?) case installed } private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { self.defaults = defaults self.enabledLocaleIDs = defaults.stringArray(forKey: Self.enabledKey) ?? [] } // MARK: - Canonical ids /// The stable key for a locale everywhere in the app: its BCP-47 identifier /// (`en-US`), so it round-trips through `Locale(identifier:)`. static func canonicalID(_ locale: Locale) -> String { locale.identifier(.bcp47) } /// The enabled languages as `Locale`s, in the order the user has them. var enabledLocales: [Locale] { enabledLocaleIDs.map(Locale.init(identifier:)) } /// Localized display name for a locale ("English (United States)"), falling /// back to the raw identifier. static func displayName(for locale: Locale) -> String { Locale.current.localizedString(forIdentifier: locale.identifier(.bcp47)) ?? locale.identifier(.bcp47) } // MARK: - Loading /// One-time load of the supported-locale list and the reservation cap, plus a /// first-run seed of the enabled set from the user's preferred languages. Idempotent /// and cheap to call again — it re-fetches the list but only seeds once. func prepare() async { maximumReserved = AssetInventory.maximumReservedLocales // Query both engines concurrently; the supported set is their union. async let speech = SpeechTranscriber.supportedLocales async let dictation = DictationTranscriber.supportedLocales let speechList = await speech let dictationList = await dictation speechLocaleIDs = Set(speechList.map(Self.canonicalID)) dictationLocaleIDs = Set(dictationList.map(Self.canonicalID)) // De-duplicate by canonical id, keeping the `SpeechTranscriber` locale // object where both engines cover it (harmless — same bcp47 either way). var byID: [String: Locale] = [:] for locale in dictationList { byID[Self.canonicalID(locale)] = locale } for locale in speechList { byID[Self.canonicalID(locale)] = locale } supportedLocales = byID.values .sorted { Self.displayName(for: $0) < Self.displayName(for: $1) } if !defaults.bool(forKey: Self.seededKey) { seedDefaults() defaults.set(true, forKey: Self.seededKey) } await refreshStates() } /// Seed the enabled set from `Locale.preferredLanguages ∩ supportedLocales`, /// matched by language code and capped at the reservation limit. Falls back to /// English (or the first supported locale) so a note recorded before the user /// visits Settings still has something to transcribe with. private func seedDefaults() { guard enabledLocaleIDs.isEmpty else { return } var seed: [String] = [] for preferred in Locale.preferredLanguages.map(Locale.init(identifier:)) { guard let match = supportedLocales.first(where: { $0.language.languageCode == preferred.language.languageCode }) else { continue } let id = Self.canonicalID(match) if !seed.contains(id) { seed.append(id) } } if seed.isEmpty { let fallback = supportedLocales.first { $0.language.languageCode?.identifier == "en" } ?? supportedLocales.first if let fallback { seed = [Self.canonicalID(fallback)] } } enabledLocaleIDs = Array(seed.prefix(max(1, maximumReserved))) persist() // Reserve the seeded languages so their models are protected from purge. Task { await self.reserveEnabled() } } private func reserveEnabled() async { for locale in enabledLocales { _ = try? await AssetInventory.reserve(locale: locale) } } /// Refresh every supported locale's asset state (installed / downloadable / /// unsupported). Runs sequentially — `status(forModules:)` is cheap but there /// are dozens of locales, and there's no need to fan out. func refreshStates() async { for locale in supportedLocales { let id = Self.canonicalID(locale) // Preserve an in-flight download's fraction rather than stomping it. if case .downloading = assetStates[id] { continue } assetStates[id] = await currentState(for: locale) } } private func currentState(for locale: Locale) async -> AssetState { switch await AssetInventory.status(forModules: [module(for: locale)]) { case .unsupported: return .unsupported case .supported: return .notInstalled case .downloading: return .downloading(nil) case .installed: return .installed @unknown default: return .notInstalled } } // MARK: - Enable / disable var isAtCapacity: Bool { enabledLocaleIDs.count >= maximumReserved } func isEnabled(_ locale: Locale) -> Bool { enabledLocaleIDs.contains(Self.canonicalID(locale)) } /// Enable a language: reserve it, mark it enabled, and download its model if /// needed (surfacing progress through `assetStates`). Throws `atCapacity` when /// already at the reservation cap. func enable(_ locale: Locale) async throws { let id = Self.canonicalID(locale) guard !enabledLocaleIDs.contains(id) else { return } guard !isAtCapacity else { throw SettingsError.atCapacity } _ = try await AssetInventory.reserve(locale: locale) enabledLocaleIDs.append(id) persist() try await installIfNeeded(locale) } /// Disable a language: drop it from the enabled set and release its /// reservation (letting the system reclaim the model later). Best-effort. func disable(_ locale: Locale) async { let id = Self.canonicalID(locale) enabledLocaleIDs.removeAll { $0 == id } persist() _ = await AssetInventory.release(reservedLocale: locale) assetStates[id] = await currentState(for: locale) } private func installIfNeeded(_ locale: Locale) async throws { let id = Self.canonicalID(locale) let transcriber = module(for: locale) guard await AssetInventory.status(forModules: [transcriber]) != .installed else { assetStates[id] = .installed return } guard let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) else { assetStates[id] = await currentState(for: locale) return } let progress = request.progress assetStates[id] = .downloading(0) // Poll the Progress so the @Observable dict updates the row; Progress KVO // doesn't drive SwiftUI on its own. let poll = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(300)) guard let self else { return } self.assetStates[id] = .downloading(progress.fractionCompleted) if progress.isFinished { return } } } defer { poll.cancel() } try await request.downloadAndInstall() assetStates[id] = await currentState(for: locale) } /// The subset of enabled languages whose models are installed — the only ones /// `TranscriptionService` can actually run. func enabledInstalledLocales() async -> [Locale] { var installed: [Locale] = [] for locale in enabledLocales where await AssetInventory.status(forModules: [module(for: locale)]) == .installed { installed.append(locale) } return installed } // MARK: - Engine routing /// Which module transcribes `locale`: `.dictation` only for a locale the /// dictation engine covers and `SpeechTranscriber` does not; `.speech` /// otherwise. This both prefers `SpeechTranscriber` when both cover a locale /// and defaults to it before `prepare()` has populated the sets (empty sets ⇒ /// `.speech` everywhere, the historical behavior) — so a transcription that /// races startup still routes sanely rather than sending English to dictation. /// A truly unsupported locale reports `.unsupported` under either engine. func engine(for locale: Locale) -> EngineKind { let id = Self.canonicalID(locale) if dictationLocaleIDs.contains(id), !speechLocaleIDs.contains(id) { return .dictation } return .speech } /// The analyzer module for a locale, built with its resolved engine. Used for /// asset queries (confidence omitted, which doesn't affect asset status) and, /// with `requestingConfidence: true`, for the transcription passes. func module(for locale: Locale, requestingConfidence: Bool = false) -> any SpeechModule { Self.module(for: locale, engine: engine(for: locale), requestingConfidence: requestingConfidence) } /// A locale's analyzer module for a given engine. Pure, so `TranscriptionService` /// can build the same module the settings screen queries. `requestingConfidence` /// asks the engine for the `.transcriptionConfidence` attribute — honored where /// the engine emits it (`SpeechTranscriber` always; `DictationTranscriber` may /// leave runs unattributed, which `LanguagePicker` degrades gracefully). static func module( for locale: Locale, engine: EngineKind, requestingConfidence: Bool = false ) -> any SpeechModule { switch engine { case .speech: return SpeechTranscriber( locale: locale, transcriptionOptions: [], reportingOptions: [], attributeOptions: requestingConfidence ? [.transcriptionConfidence] : [] ) case .dictation: return DictationTranscriber( locale: locale, contentHints: [], transcriptionOptions: [], reportingOptions: [], attributeOptions: requestingConfidence ? [.transcriptionConfidence] : [] ) } } private func persist() { defaults.set(enabledLocaleIDs, forKey: Self.enabledKey) } enum SettingsError: Error { case atCapacity } }