Add voice notes: on-device transcription, multi-language, watch capture

- Schema v2: Note.AudioInfo, .m4a sidecar next to the note JSON in iCloud
- AudioRecorderService (iOS/macOS) + SpeechAnalyzer transcription pipeline
  with enabled-language set and confidence-based auto-pick, re-transcribe menu
- Settings > Transcription > Languages with asset download/reserve handling
- watchOS app + accessory complication: one-button recorder, WCSession
  transferFile to phone, WatchInbox staging, direct-upsert ingestion
- Player UI, transcription status chips, quick-capture mic in notes list
- Version 0.2, changelog + README updated
This commit is contained in:
2026-07-15 13:30:43 -04:00
parent 6a93cedaa6
commit 86d74806c9
44 changed files with 3329 additions and 123 deletions
+190
View File
@@ -0,0 +1,190 @@
import AVFoundation
import Foundation
import IndieSync
/// Records a single voice note into a temporary `<ULID>.m4a` (AAC mono
/// 44.1 kHz ~48 kbps). Modeled on `ContextService`: `@Observable @MainActor`,
/// so the UI observes `isRecording` / `elapsed` / `level` directly.
///
/// Never records into the iCloud container the file is written to the temp
/// directory and handed up as a URL; `SyncEngine.createAudioNote` is what copies
/// the finished bytes into the container (audio before JSON). `AVAudioRecorder`
/// is not Sendable, so it stays confined to the main actor for its whole life.
@Observable
@MainActor
final class AudioRecorderService {
/// One finished recording: the temp file and how long it runs. The caller
/// owns the file from here on (moves its bytes into the container, then
/// discards the temp copy).
struct Recording: Equatable {
let url: URL
let duration: TimeInterval
}
private(set) var isRecording = false
/// Seconds recorded so far, updated by the metering loop while recording.
private(set) var elapsed: TimeInterval = 0
/// Normalized input level (01) for the level-bar UI; 0 when idle.
private(set) var level: Float = 0
/// Fired when a system interruption (phone call, Siri) forces recording to
/// stop. The partial file is finalized and handed up rather than lost, so the
/// UI can move straight to the "recorded" state. nil unless a recorder wires
/// it up for the duration of a take.
var onInterrupted: ((Recording) -> Void)?
private var recorder: AVAudioRecorder?
private var meterTask: Task<Void, Never>?
private var currentURL: URL?
#if os(iOS)
private var interruptionObserver: (any NSObjectProtocol)?
#endif
/// Ask for microphone access, returning whether it was granted. Safe to call
/// every time recording starts the system only prompts once.
func requestPermission() async -> Bool {
await AVAudioApplication.requestRecordPermission()
}
/// Whether the mic has been explicitly denied (so the UI can show a jump to
/// Settings instead of a dead record button).
var permissionDenied: Bool {
AVAudioApplication.shared.recordPermission == .denied
}
/// Begin recording into a fresh temp file. Throws if the audio session or
/// recorder can't be configured (surfaced by the UI). A no-op if already
/// recording.
func startRecording() throws {
guard !isRecording else { return }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .spokenAudio, options: [.duckOthers])
try session.setActive(true)
observeInterruptions()
#endif
let url = FileManager.default.temporaryDirectory
.appending(path: "\(ULID().stringValue).m4a")
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 48_000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.isMeteringEnabled = true
guard recorder.record() else {
throw RecorderError.couldNotStart
}
self.recorder = recorder
self.currentURL = url
isRecording = true
elapsed = 0
level = 0
startMetering()
}
/// Stop recording and return the finished file plus its duration, or nil if
/// nothing was recording. Deactivates the audio session on iOS.
@discardableResult
func stopRecording() -> Recording? {
guard let recorder, let url = currentURL else { return nil }
let duration = recorder.currentTime
recorder.stop()
teardown()
return Recording(url: url, duration: duration)
}
/// Abandon the in-progress (or just-finished) recording and delete its temp
/// file. Used by "Discard" and when the composer is dismissed unsaved.
func discard() {
recorder?.stop()
if let url = currentURL {
try? FileManager.default.removeItem(at: url)
}
teardown()
}
// MARK: - Metering
/// ~30 Hz loop that refreshes `elapsed` and `level` while recording. Runs on
/// the main actor (a plain `Task` inherits this actor), so it can touch the
/// non-Sendable recorder safely.
private func startMetering() {
meterTask?.cancel()
meterTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, let recorder = self.recorder, recorder.isRecording else { return }
recorder.updateMeters()
self.elapsed = recorder.currentTime
self.level = Self.normalizedLevel(recorder.averagePower(forChannel: 0))
try? await Task.sleep(for: .milliseconds(33))
}
}
}
/// Map a dBFS reading (roughly -600) to a 01 bar height.
private static func normalizedLevel(_ power: Float) -> Float {
let floorDB: Float = -50
guard power > floorDB else { return 0 }
return min(1, (power - floorDB) / -floorDB)
}
private func teardown() {
meterTask?.cancel()
meterTask = nil
recorder = nil
currentURL = nil
isRecording = false
elapsed = 0
level = 0
#if os(iOS)
if let interruptionObserver {
NotificationCenter.default.removeObserver(interruptionObserver)
self.interruptionObserver = nil
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
// MARK: - Interruptions (iOS)
#if os(iOS)
private func observeInterruptions() {
guard interruptionObserver == nil else { return }
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
// Extract the Sendable interruption type before hopping the
// Notification itself isn't Sendable. Delivered on the main queue, so
// assuming main-actor isolation is sound.
let began = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt)
.flatMap(AVAudioSession.InterruptionType.init(rawValue:)) == .began
guard began else { return }
MainActor.assumeIsolated {
self?.handleInterruptionBegan()
}
}
}
private func handleInterruptionBegan() {
guard isRecording else { return }
// Finalize rather than drop the take the partial recording is still a
// valid note.
if let finished = stopRecording() {
onInterrupted?(finished)
}
}
#endif
enum RecorderError: Error {
case couldNotStart
}
}
+24
View File
@@ -0,0 +1,24 @@
import Foundation
/// Stable per-install identifier a UUID minted once on first access and kept
/// in UserDefaults for the life of the install. Distinct from the iCloud
/// account: two devices signed into the same account get different install IDs.
///
/// It answers "which device owns a pending transcription?" A voice note stamps
/// the recording device's `installID` into `AudioInfo.transcriberDeviceID`; only
/// the device whose `installID` matches transcribes it, while the others render
/// status only. Re-transcribing on any device reassigns the id to itself, which
/// is also the recovery path when the original recording device is gone.
enum InstallIdentity {
private static let key = "Notes.installID"
/// The per-install UUID, created lazily on first read and reused forever.
static var installID: String {
if let existing = UserDefaults.standard.string(forKey: key) {
return existing
}
let fresh = UUID().uuidString
UserDefaults.standard.set(fresh, forKey: key)
return fresh
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import Speech
/// One language's showing in the sampling pass: how well `SpeechTranscriber`
/// transcribed the first few seconds of audio in that locale.
struct LanguageCandidate: Equatable {
/// BCP-47 identifier of the locale (e.g. `en-US`, `ru-RU`).
let localeID: String
/// Character-count-weighted mean of the per-run transcription confidence,
/// 01. A locale that produced no text scores 0.
let meanConfidence: Double
/// Number of characters the transcript came out to the tie-breaker and the
/// reason an empty transcript can't win.
let transcriptLength: Int
}
/// Pure, testable language selection for a voice note. No `SpeechAnalyzer` calls
/// live here: the service transcribes a ~15 s sample in each enabled language,
/// turns each into a `LanguageCandidate`, and this picks the winner. Keeping it
/// side-effect-free is what makes the (undocumented, cross-locale) confidence
/// calibration unit-testable.
enum LanguagePicker {
/// Character-count-weighted mean confidence over an attributed transcript.
/// Only runs that actually carry a `transcriptionConfidence` attribute
/// contribute, weighted by their character length; a transcript with no
/// confidence anywhere (or no characters) scores 0.
static func meanConfidence(of attributed: AttributedString) -> Double {
var weighted = 0.0
var totalCharacters = 0
for run in attributed.runs {
guard let confidence = run.transcriptionConfidence else { continue }
let length = attributed[run.range].characters.count
guard length > 0 else { continue }
weighted += confidence * Double(length)
totalCharacters += length
}
return totalCharacters > 0 ? weighted / Double(totalCharacters) : 0
}
/// The best candidate, or nil when given nothing. Argmax on `meanConfidence`;
/// a zero-length transcript can only win if every candidate is empty. Ties
/// break deterministically higher confidence, then longer transcript, then
/// lexicographically smaller `localeID` so the same samples always yield the
/// same pick.
static func pick(from candidates: [LanguageCandidate]) -> LanguageCandidate? {
candidates.max { lhs, rhs in
if lhs.meanConfidence != rhs.meanConfidence {
return lhs.meanConfidence < rhs.meanConfidence
}
if lhs.transcriptLength != rhs.transcriptLength {
return lhs.transcriptLength < rhs.transcriptLength
}
// `max` keeps the greater element; invert so the smaller localeID wins.
return lhs.localeID > rhs.localeID
}
}
}
+252
View File
@@ -0,0 +1,252 @@
import AVFoundation
import Foundation
import IndieSync
import Speech
import SwiftData
/// On-device, after-the-fact transcription of voice notes. `@Observable
/// @MainActor` with a FIFO one-at-a-time queue: a `SpeechAnalyzer` is memory- and
/// model-heavy, so notes are transcribed strictly one after another. The analyzer
/// work is awaited (the analyzer is an actor), so it doesn't block the main actor
/// despite this class being main-actor confined.
///
/// Only this device's own pending notes are ever enqueued ownership is decided
/// by `AudioInfo.transcriberDeviceID` upstream (`SyncEngine`), which this service
/// trusts. A failed job is terminal until the user asks to retry.
@Observable
@MainActor
final class TranscriptionService {
private let syncEngine: SyncEngine
private let settings: TranscriptionSettings
private let modelContainer: ModelContainer
/// Seconds of audio the sampling pass listens to when choosing a language.
private static let sampleSeconds: Double = 15
private struct Job {
let noteID: ULID
/// A fresh recording's temp file, passed on enqueue for the fast path; nil
/// for re-transcription, which pulls the audio back down from the container.
let localAudioURL: URL?
/// A forced language (re-transcribe menu), else auto-select.
let forcedLocaleID: String?
}
private var queue: [Job] = []
/// Ids currently queued or in flight, so overlapping triggers (launch resume,
/// watch ingest, a re-transcribe) can't enqueue the same note twice.
private var inFlightIDs: Set<String> = []
private var pumpTask: Task<Void, Never>?
init(syncEngine: SyncEngine, settings: TranscriptionSettings, modelContainer: ModelContainer) {
self.syncEngine = syncEngine
self.settings = settings
self.modelContainer = modelContainer
}
// MARK: - Queue
/// Queue a note for transcription. `localAudioURL` is the fresh-recording fast
/// path (skips the container round-trip); `forcedLocaleID` skips language
/// auto-selection. Duplicate enqueues of an in-flight note are ignored.
func enqueue(noteID: ULID, localAudioURL: URL? = nil, forcedLocaleID: String? = nil) {
let key = noteID.stringValue
guard !inFlightIDs.contains(key) else { return }
inFlightIDs.insert(key)
queue.append(Job(noteID: noteID, localAudioURL: localAudioURL, forcedLocaleID: forcedLocaleID))
pump()
}
/// On launch, re-enqueue every note this device owns that is still pending
/// recovering work that a crash or kill interrupted mid-transcription. Reads
/// the cache (already rebuilt by the sync engine's connect).
func resumePendingWork() {
let context = modelContainer.mainContext
let installID = TranscriptionSettings.installID
let pendingRaw = Note.TranscriptionStatus.pending.rawValue
let descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate {
$0.hasAudio
&& $0.transcriptionStatusRaw == pendingRaw
&& $0.transcriberDeviceID == installID
})
let rows = (try? context.fetch(descriptor)) ?? []
for row in rows {
if let id = ULID(string: row.id) { enqueue(noteID: id) }
}
}
private func pump() {
guard pumpTask == nil else { return }
pumpTask = Task { [weak self] in
while let self, let job = self.dequeue() {
await self.process(job)
self.inFlightIDs.remove(job.noteID.stringValue)
}
self?.pumpTask = nil
}
}
private func dequeue() -> Job? {
queue.isEmpty ? nil : queue.removeFirst()
}
// MARK: - Processing
/// Transcribe one job, retrying once before marking the note failed. A failed
/// note is left for the user to retry explicitly (re-transcribe / Retry).
private func process(_ job: Job) async {
do {
try await transcribe(job)
} catch {
do {
try await transcribe(job)
} catch {
print("[Transcription] \(job.noteID.stringValue) failed: \(error)")
try? await syncEngine.markTranscriptionFailed(id: job.noteID)
}
}
}
private func transcribe(_ job: Job) async throws {
let audio = try await resolveAudioFile(for: job)
defer { cleanUp(audio: audio, job: job) }
let locale = try await selectLocale(fileURL: audio.url, forcedLocaleID: job.forcedLocaleID)
let transcript = try await runTranscription(fileURL: audio.url, locale: locale)
let text = String(transcript.characters).trimmingCharacters(in: .whitespacesAndNewlines)
try await syncEngine.applyTranscription(
id: job.noteID,
text: text,
localeID: TranscriptionSettings.canonicalID(locale)
)
}
// MARK: - Audio acquisition
private struct AudioFile {
let url: URL
/// Whether we wrote a temp copy that must be cleaned up after the job.
let isTemporary: Bool
}
/// The fresh recording if it's still on disk, else a temp copy pulled back
/// from the container (bounded wait for an evicted file).
private func resolveAudioFile(for job: Job) async throws -> AudioFile {
if let local = job.localAudioURL, FileManager.default.fileExists(atPath: local.path) {
return AudioFile(url: local, isTemporary: false)
}
let data = try await syncEngine.loadAudioDataWaiting(for: job.noteID)
let url = FileManager.default.temporaryDirectory
.appending(path: "transcribe-\(job.noteID.stringValue).m4a")
try data.write(to: url)
return AudioFile(url: url, isTemporary: true)
}
private func cleanUp(audio: AudioFile, job: Job) {
let fm = FileManager.default
if audio.isTemporary { try? fm.removeItem(at: audio.url) }
// A fresh recording's temp file is no longer needed once the container
// holds the bytes; drop it to keep the temp dir tidy.
if let local = job.localAudioURL,
local.path.hasPrefix(fm.temporaryDirectory.path) {
try? fm.removeItem(at: local)
}
}
// MARK: - Language selection
private func selectLocale(fileURL: URL, forcedLocaleID: String?) async throws -> Locale {
if let forcedLocaleID {
return Locale(identifier: forcedLocaleID)
}
let candidates = await settings.enabledInstalledLocales()
guard !candidates.isEmpty else { throw TranscriptionError.noInstalledLanguage }
if candidates.count == 1 { return candidates[0] }
// Sampling pass: transcribe a short clip in each language and pick the
// most confident. Run sequentially one analyzer at a time keeps the
// memory ceiling low.
let sample = try makeSampleClip(from: fileURL, seconds: Self.sampleSeconds)
defer { if sample.isTemporary { try? FileManager.default.removeItem(at: sample.url) } }
var scored: [LanguageCandidate] = []
for locale in candidates {
let transcript = (try? await runTranscription(fileURL: sample.url, locale: locale)) ?? AttributedString()
scored.append(LanguageCandidate(
localeID: TranscriptionSettings.canonicalID(locale),
meanConfidence: LanguagePicker.meanConfidence(of: transcript),
transcriptLength: transcript.characters.count
))
}
guard let winner = LanguagePicker.pick(from: scored) else { return candidates[0] }
return Locale(identifier: winner.localeID)
}
/// The first `seconds` of the recording as its own file, so the sampling pass
/// isn't run over a whole long note. Returns the original file (no copy) when
/// the recording is already shorter than the sample window.
private func makeSampleClip(from fileURL: URL, seconds: Double) throws -> AudioFile {
let source = try AVAudioFile(forReading: fileURL)
let format = source.processingFormat
let wantFrames = format.sampleRate * seconds
guard Double(source.length) > wantFrames else {
return AudioFile(url: fileURL, isTemporary: false)
}
let frameCount = AVAudioFrameCount(wantFrames)
guard frameCount > 0, let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
throw TranscriptionError.audioUnreadable
}
try source.read(into: buffer, frameCount: frameCount)
let clipURL = FileManager.default.temporaryDirectory
.appending(path: "sample-\(UUID().uuidString).caf")
let clip = try AVAudioFile(forWriting: clipURL, settings: format.settings)
try clip.write(from: buffer)
return AudioFile(url: clipURL, isTemporary: true)
}
// MARK: - Analyzer
/// Transcribe a whole file in one locale and return the finalized attributed
/// transcript (carrying per-run confidence). Follows the documented pattern:
/// start a collector over `transcriber.results`, feed the file, finalize.
private func runTranscription(fileURL: URL, locale: Locale) async throws -> AttributedString {
let transcriber = SpeechTranscriber(
locale: locale,
transcriptionOptions: [],
reportingOptions: [],
attributeOptions: [.transcriptionConfidence]
)
let analyzer = SpeechAnalyzer(
modules: [transcriber],
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
)
let file = try AVAudioFile(forReading: fileURL)
// Collect finalized results concurrently; `results` terminates when the
// analyzer finishes, ending the loop.
let collector = Task {
var full = AttributedString()
for try await result in transcriber.results where result.isFinal {
full.append(result.text)
}
return full
}
do {
if let lastSample = try await analyzer.analyzeSequence(from: file) {
try await analyzer.finalizeAndFinish(through: lastSample)
} else {
await analyzer.cancelAndFinishNow()
}
} catch {
collector.cancel()
throw error
}
return try await collector.value
}
enum TranscriptionError: Error {
case audioUnreadable
case noInstalledLanguage
}
}
+231
View File
@@ -0,0 +1,231 @@
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.
@Observable
@MainActor
final class TranscriptionSettings {
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 `SpeechTranscriber` can transcribe (36+), loaded once by
/// `prepare()`. Empty until then.
private(set) var supportedLocales: [Locale] = []
/// 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
supportedLocales = await SpeechTranscriber.supportedLocales
.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: [Self.transcriber(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 = Self.transcriber(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: [Self.transcriber(for: locale)]) == .installed {
installed.append(locale)
}
return installed
}
// MARK: - Helpers
/// A minimal transcriber for a locale, used purely for asset queries. The
/// attribute options don't affect asset status, so they're omitted here.
static func transcriber(for locale: Locale) -> SpeechTranscriber {
SpeechTranscriber(
locale: locale,
transcriptionOptions: [],
reportingOptions: [],
attributeOptions: []
)
}
private func persist() {
defaults.set(enabledLocaleIDs, forKey: Self.enabledKey)
}
enum SettingsError: Error {
case atCapacity
}
}