diff --git a/Apps/Attendi/Models/AppNotification.swift b/Apps/Attendi/Models/AppNotification.swift new file mode 100644 index 0000000..4c767bb --- /dev/null +++ b/Apps/Attendi/Models/AppNotification.swift @@ -0,0 +1,49 @@ +import Foundation + +/// An in-app notification reporting an event of a speech model asset download. +struct AppNotification: Equatable, Identifiable { + + // MARK: Properties + + /// The unique identifier of the notification. + let id: UUID + + /// The download event the notification reports. + let event: Event + + /// The localized name of the locale whose speech model the event is about. + let localeName: String + + // MARK: Initializers + + /// Creates a notification for a given download event and locale name. + /// + /// - Parameters: + /// - id: The unique identifier of the notification. Defaults to a newly generated identifier. + /// - event: The download event the notification reports. + /// - localeName: The localized name of the locale whose speech model the event is about. + init( + id: UUID = .init(), + event: Event, + localeName: String + ) { + self.id = id + self.event = event + self.localeName = localeName + } + +} + +// MARK: - Enumerations + +extension AppNotification { + /// The download event the notification reports. + enum Event: Sendable { + /// The download was cancelled before it finished. + case cancelled + /// The download failed. + case failed + /// The download started. + case started + } +} diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift index 45dab79..a282b25 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Notifying import Observation import OSLog import Recording @@ -12,6 +13,10 @@ extension ContentView { /// 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 { @@ -27,10 +32,17 @@ extension ContentView { /// 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( @@ -38,6 +50,10 @@ extension ContentView { 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 @@ -59,50 +75,36 @@ extension ContentView { /// 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 + 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 } - if let equivalent = await SpeechTranscriber.supportedLocale(equivalentTo: locale) { - locale = equivalent - } else if let fallback = await SpeechTranscriber.supportedLocale(equivalentTo: .byDefault) { - locale = fallback - } + preinstall() } - - /// Preinstalls the speech model assets for the supported equivalent of ``locale``, so the first transcription in that locale - /// does not have to download them mid-processing. - /// - /// 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: the transcribing service installs any missing assets itself as a fallback when a - /// transcription starts. - func preinstallAssets() async { - guard let locale = await SpeechTranscriber.supportedLocale( - equivalentTo: locale - ) else { + + /// 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 } - for reserved in await AssetInventory.reservedLocales where reserved != locale { - _ = await AssetInventory.release(reservedLocale: reserved) - } + taskPreinstall?.cancel() - let transcriber = SpeechTranscriber( - locale: locale, - preset: .transcription - ) + localePreinstalling = locale - do { - guard let request = try await AssetInventory.assetInstallationRequest( - supporting: [transcriber] - ) else { - return - } - - try await request.downloadAndInstall() - } catch { - logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)") + taskPreinstall = Task { [locale] in + await preinstallAssets(for: locale) } } @@ -139,8 +141,110 @@ extension ContentView { } +// 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, 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) + + 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") diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index ad00b7e..f2e929b 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ Attendi/App/AttendiApp.swift, Attendi/Catalogs/Assets.xcassets, Attendi/Catalogs/Localizable.xcstrings, + Attendi/Models/AppNotification.swift, "Attendi/View Models/ContentViewModel.swift", Attendi/Views/ContentView.swift, );