import Accessibility import Foundation import Observation import OSLog import Recording import Speech extension ContentView { /// The observable model that drives ``ContentView``. /// /// The model owns the recording and transcribing services attached to the recording feature, along with the ``locale`` the feature /// transcribes in, picked in the view's toolbar from the supported ``locales`` that ``load()`` fetches. It also holds the transcription /// currently presented in the view's modal sheet: ``received(_:)`` presents the transcription of a processed recording, and /// ``dismissed()`` clears it when the sheet closes. /// /// Every picked locale kicks off ``preinstall()``, a cancellable task that preinstalls the locale's speech model assets — cancelling /// any download still in flight for a previously picked locale — and reports the started, cancelled, and failed downloads through the /// transient, self-dismissing ``notifications``. @MainActor @Observable final class Model { // MARK: Properties /// The locale of the spoken language to transcribe. var locale: Locale /// The transcription currently presented in the modal sheet, or `nil` when none is shown. var transcription: Transcription? /// The locales the transcriber supports, sorted by their localized names, or empty while ``load()`` has not finished yet. private(set) var locales: [Locale] /// The in-app notifications currently presented, each dismissing itself a few seconds after it was posted. private(set) var notifications: [AppNotification] = [] /// The service that captures the audio from the device's microphone. @ObservationIgnored let capturer: AudioCapturing /// The locale whose speech model assets are preinstalled or being preinstalled, or `nil` when none are. @ObservationIgnored private var localePreinstalling: Locale? /// The logger that records the failures of the speech model asset management. @ObservationIgnored private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "Attendi", category: "ContentView.Model" ) /// The task that preinstalls the speech model assets of the last picked locale, or `nil` until the first locale is picked. @ObservationIgnored private var taskPreinstall: Task? /// The service that transcribes the recorded audio into text on device. @ObservationIgnored let transcriber: AudioTranscribing // MARK: Initializers /// Creates a model set to the user's current locale, with no supported locales loaded yet, attached to the on-device recording /// and transcribing services. init() { self.locale = .current self.locales = [] self.capturer = .init() self.transcriber = .init() } // MARK: Methods /// Loads the locales the transcriber supports into ``locales``, sorted by their localized names, and aligns ``locale`` with the /// supported equivalent of its current value — falling back to the supported equivalent of a default locale when none exists — /// so the locale picker starts with a valid selection. func load() async { locales = await SpeechTranscriber.supportedLocales .sorted { name(for: $0).localizedStandardCompare(name(for: $1)) == .orderedAscending } locale = if let equivalent = await localeInTranscriber(equivalentTo: locale) { equivalent } else if let fallback = await localeInTranscriber(equivalentTo: .byDefault) { fallback } else { .current } preinstall() } /// Preinstalls the speech model assets for the current ``locale`` in a cancellable task, cancelling the preinstallation of any /// previously picked locale still in flight — which posts its cancellation to ``notifications``. Does nothing when the current /// locale is the one already preinstalled or being preinstalled. func preinstall() { guard localePreinstalling != locale else { return } taskPreinstall?.cancel() localePreinstalling = locale taskPreinstall = Task { [locale] in await preinstallAssets(for: locale) } } /// Returns the localized name of a locale for the locale picker. /// /// - Parameter locale: The locale to name. /// - Returns: The name of the locale in the user's current locale, or its identifier when no name is available. func name( for locale: Locale ) -> String { Locale .current .localizedString( forIdentifier: locale.identifier )?.localizedCapitalized ?? locale.identifier } /// Handles the transcription of a processed recording, presenting it in the modal sheet. /// /// - Parameter transcription: The transcription of the processed recording. func received( _ transcription: Transcription ) { self.transcription = transcription } /// Handles the dismissal of the modal sheet. func dismissed() { transcription = nil } } } // MARK: - Helpers private extension ContentView.Model { // MARK: Methods func localeInTranscriber( equivalentTo locale: Locale ) async -> Locale? { await SpeechTranscriber.supportedLocale(equivalentTo: locale) } /// Posts a notification for a download event of the given locale, announcing it to assistive technologies and scheduling its /// automatic dismissal a few seconds later — the only way a notification is dismissed. /// /// - Parameters: /// - event: The download event to notify. /// - locale: The locale whose speech model the event is about. func post( _ event: AppNotification.Event, for locale: Locale ) { let notification = AppNotification( event: event, localeName: name(for: locale) ) notifications.append(notification) AccessibilityNotification.Announcement( String(localized: notification.textMessage) ).post() Task { try? await Task.sleep(for: Constant.Delay.dismissal) notifications.removeAll { $0.id == notification.id } } } /// Preinstalls the speech model assets for the supported equivalent of the given locale, so the first transcription in that locale /// does not have to download them mid-processing — posting a notification when a download starts, is cancelled, or fails; a /// download that finishes, or assets that are already installed, post none. /// /// The locale reservations of any other locales are released beforehand: the app transcribes a single locale at a time, and /// the system only permits a limited number of reservations — exceeding it would make the installation request throw. /// Failures are logged but not surfaced as errors: the transcribing service installs any missing assets itself as a fallback when /// a transcription starts. /// /// - Parameter target: The picked locale to preinstall the speech model assets for. func preinstallAssets( for target: Locale ) async { guard let locale = await localeInTranscriber(equivalentTo: target) else { return } for reserved in await AssetInventory.reservedLocales where reserved != locale { _ = await AssetInventory.release(reservedLocale: reserved) } let transcriber = SpeechTranscriber( locale: locale, preset: .transcription ) do { guard let request = try await AssetInventory.assetInstallationRequest( supporting: [transcriber] ) else { return } post(.started, for: locale) try await withTaskCancellationHandler { try await request.downloadAndInstall() } onCancel: { [progress = request.progress] in progress.cancel() } } catch { if localePreinstalling == target { localePreinstalling = nil } if error is CancellationError || Task.isCancelled { post(.cancelled, for: locale) } else { post(.failed, for: locale) logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)") } } } } // MARK: - Constants /// The constant values used across the model. private enum Constant { /// The delay constants. enum Delay { /// The time a posted notification stays visible before its automatic dismissal. static let dismissal: Duration = .seconds(4) } } private extension Locale { /// The locale to fall back to when the transcriber supports no equivalent of the user's current locale. static let byDefault: Locale = .init(identifier: "en_US") }