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.
@ 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-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-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-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 {
locales = await SpeechTranscriber . supportedLocales . sorted {
2026-07-04 13:25:15 +02:00
name ( for : $0 ). localizedStandardCompare ( name ( for : $1 )) == . orderedAscending
2026-07-04 12:59:18 +02:00
}
2026-07-04 14:58:14 +02:00
if let equivalent = await SpeechTranscriber . supportedLocale ( equivalentTo : locale ) {
locale = equivalent
} else if let fallback = await SpeechTranscriber . supportedLocale ( equivalentTo : . byDefault ) {
locale = fallback
}
2026-07-04 12:59:18 +02:00
}
2026-07-04 15:41:45 +02:00
/// 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.
///
2026-07-04 15:58:32 +02:00
/// 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
2026-07-04 15:41:45 +02:00
/// transcription starts.
func preinstallAssets () async {
guard let locale = await SpeechTranscriber . supportedLocale (
equivalentTo : locale
) else {
return
}
2026-07-04 15:58:32 +02:00
for reserved in await AssetInventory . reservedLocales where reserved != locale {
_ = await AssetInventory . release ( reservedLocale : reserved )
}
2026-07-04 15:41:45 +02:00
let transcriber = SpeechTranscriber (
locale : locale ,
preset : . transcription
)
2026-07-04 15:58:32 +02:00
do {
guard let request = try await AssetInventory . assetInstallationRequest (
supporting : [ transcriber ]
) else {
return
}
2026-07-04 15:41:45 +02:00
2026-07-04 15:58:32 +02:00
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 ) " )
}
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
// MARK: - Constants
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" )
}