2026-07-05 13:06:43 +02:00
import Accessibility
2026-07-04 10:32:59 +02:00
import Foundation
import Observation
2026-07-04 15:58:32 +02:00
import OSLog
2026-07-04 10:32:59 +02:00
import Recording
2026-07-04 12:59:18 +02:00
import Speech
2026-07-04 10:32:59 +02:00
extension ContentView {
/// The observable model that drives ``ContentView``.
///
2026-07-04 12:59:18 +02:00
/// 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
2026-07-04 10:32:59 +02:00
/// ``dismissed()`` clears it when the sheet closes.
2026-07-05 11:51:39 +02:00
///
/// 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``.
2026-07-04 10:32:59 +02:00
@ MainActor
@ Observable
final class Model {
// MARK: Properties
2026-07-04 12:11:24 +02:00
/// The locale of the spoken language to transcribe.
2026-07-04 12:59:18 +02:00
var locale : Locale
2026-07-04 10:32:59 +02:00
/// The transcription currently presented in the modal sheet, or `nil` when none is shown.
var transcription : Transcription ?
2026-07-04 12:59:18 +02:00
/// The locales the transcriber supports, sorted by their localized names, or empty while ``load()`` has not finished yet.
private ( set ) var locales : [ Locale ]
2026-07-05 11:51:39 +02:00
/// The in-app notifications currently presented, each dismissing itself a few seconds after it was posted.
private ( set ) var notifications : [ AppNotification ] = []
2026-07-04 10:32:59 +02:00
/// The service that captures the audio from the device's microphone.
@ ObservationIgnored
2026-07-04 13:41:56 +02:00
let capturer : AudioCapturing
2026-07-04 10:32:59 +02:00
2026-07-05 11:51:39 +02:00
/// The locale whose speech model assets are preinstalled or being preinstalled, or `nil` when none are.
@ ObservationIgnored
private var localePreinstalling : Locale ?
2026-07-04 15:58:32 +02:00
/// 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"
)
2026-07-05 11:51:39 +02:00
/// 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 < Void , Never >?
2026-07-04 10:32:59 +02:00
/// The service that transcribes the recorded audio into text on device.
@ ObservationIgnored
2026-07-04 12:59:18 +02:00
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 = []
2026-07-04 13:41:56 +02:00
self . capturer = . init ()
2026-07-04 12:59:18 +02:00
self . transcriber = . init ()
}
2026-07-04 10:32:59 +02:00
// MARK: Methods
2026-07-04 12:59:18 +02:00
/// Loads the locales the transcriber supports into ``locales``, sorted by their localized names, and aligns ``locale`` with the
2026-07-04 14:58:14 +02:00
/// 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.
2026-07-04 12:59:18 +02:00
func load () async {
2026-07-05 11:51:39 +02:00
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
2026-07-04 12:59:18 +02:00
}
2026-07-04 14:58:14 +02:00
2026-07-05 11:51:39 +02:00
preinstall ()
2026-07-04 12:59:18 +02:00
}
2026-07-05 11:51:39 +02:00
/// 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 {
2026-07-04 15:41:45 +02:00
return
}
2026-07-05 11:51:39 +02:00
taskPreinstall ?. cancel ()
2026-07-04 15:58:32 +02:00
2026-07-05 11:51:39 +02:00
localePreinstalling = locale
2026-07-04 15:41:45 +02:00
2026-07-05 11:51:39 +02:00
taskPreinstall = Task { [ locale ] in
await preinstallAssets ( for : locale )
2026-07-04 15:58:32 +02:00
}
2026-07-04 15:41:45 +02:00
}
2026-07-04 12:59:18 +02:00
/// 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 {
2026-07-04 14:58:14 +02:00
Locale
. current
2026-07-04 12:59:18 +02:00
. localizedString (
forIdentifier : locale . identifier
)?. localizedCapitalized
?? locale . identifier
}
2026-07-04 12:11:24 +02:00
/// Handles the transcription of a processed recording, presenting it in the modal sheet.
2026-07-04 10:32:59 +02:00
///
2026-07-04 12:11:24 +02:00
/// - Parameter transcription: The transcription of the processed recording.
2026-07-04 10:32:59 +02:00
func received (
2026-07-04 12:11:24 +02:00
_ transcription : Transcription
2026-07-04 10:32:59 +02:00
) {
2026-07-04 12:11:24 +02:00
self . transcription = transcription
2026-07-04 10:32:59 +02:00
}
/// Handles the dismissal of the modal sheet.
func dismissed () {
transcription = nil
}
}
}
2026-07-04 14:58:14 +02:00
2026-07-05 11:51:39 +02:00
// MARK: - Helpers
private extension ContentView . Model {
// MARK: Methods
func localeInTranscriber (
equivalentTo locale : Locale
) async -> Locale ? {
await SpeechTranscriber . supportedLocale ( equivalentTo : locale )
}
2026-07-05 13:06:43 +02:00
/// 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.
2026-07-05 11:51:39 +02:00
///
/// - 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 )
2026-07-05 13:06:43 +02:00
AccessibilityNotification . Announcement (
String ( localized : notification . textMessage )
). post ()
2026-07-05 11:51:39 +02:00
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 ) " )
}
}
}
}
2026-07-04 14:58:14 +02:00
// MARK: - Constants
2026-07-05 11:51:39 +02:00
/// 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 )
}
}
2026-07-04 14:58:14 +02:00
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" )
}