Add dictation-locale transcription (incl. Russian), rework list capture UI

- TranscriptionSettings/Service route per-locale to SpeechTranscriber or
  DictationTranscriber (union of both supported sets, speech preferred);
  neutral-confidence ranking fallback keeps mixed-engine auto-pick working
- NotesListView: text + voice capture buttons above the list (glassProminent),
  search moved to the iOS 26 toolbar search accessory (.minimize)
- Changelog entries for languages, capture buttons, search placement
This commit is contained in:
2026-07-15 15:31:03 -04:00
parent 3a9c36d843
commit 8bdda913a7
6 changed files with 313 additions and 76 deletions
+94 -16
View File
@@ -10,9 +10,24 @@ import Speech
/// *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.
///
/// The supported set is the *union* of two on-device SpeechAnalyzer modules'
/// coverage: `SpeechTranscriber` (30 locales) and `DictationTranscriber` (54,
/// including ru-RU, uk-UA, pl-PL that `SpeechTranscriber` lacks). Each locale
/// is routed to whichever engine supports it, preferring `SpeechTranscriber`
/// when both do (see `engine(for:)`). Reservation is locale-based and engine-
/// agnostic; only asset queries and analyzer construction pick a module type.
@Observable
@MainActor
final class TranscriptionSettings {
/// Which SpeechAnalyzer module transcribes a given locale. `SpeechTranscriber`
/// is preferred where available (it reports per-run confidence the sampling
/// pass relies on); `DictationTranscriber` covers the locales it doesn't.
enum EngineKind: Equatable {
case speech
case dictation
}
private static let enabledKey = "Notes.enabledTranscriptionLocales"
private static let seededKey = "Notes.transcriptionLocalesSeeded"
@@ -24,10 +39,20 @@ final class TranscriptionSettings {
/// 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.
/// Every locale either engine can transcribe the union of `SpeechTranscriber`
/// and `DictationTranscriber` coverage, de-duplicated by canonical id and
/// loaded once by `prepare()`. Empty until then.
private(set) var supportedLocales: [Locale] = []
/// Canonical ids `SpeechTranscriber` supports, for engine resolution. A locale
/// in this set is routed to `.speech`; anything else supported falls to
/// `.dictation`. Populated by `prepare()`.
private var speechLocaleIDs: Set<String> = []
/// Canonical ids `DictationTranscriber` supports, for engine resolution and
/// for distinguishing a genuinely unsupported locale from a dictation-only one.
private var dictationLocaleIDs: Set<String> = []
/// 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
@@ -74,7 +99,22 @@ final class TranscriptionSettings {
/// and cheap to call again it re-fetches the list but only seeds once.
func prepare() async {
maximumReserved = AssetInventory.maximumReservedLocales
supportedLocales = await SpeechTranscriber.supportedLocales
// Query both engines concurrently; the supported set is their union.
async let speech = SpeechTranscriber.supportedLocales
async let dictation = DictationTranscriber.supportedLocales
let speechList = await speech
let dictationList = await dictation
speechLocaleIDs = Set(speechList.map(Self.canonicalID))
dictationLocaleIDs = Set(dictationList.map(Self.canonicalID))
// De-duplicate by canonical id, keeping the `SpeechTranscriber` locale
// object where both engines cover it (harmless same bcp47 either way).
var byID: [String: Locale] = [:]
for locale in dictationList { byID[Self.canonicalID(locale)] = locale }
for locale in speechList { byID[Self.canonicalID(locale)] = locale }
supportedLocales = byID.values
.sorted { Self.displayName(for: $0) < Self.displayName(for: $1) }
if !defaults.bool(forKey: Self.seededKey) {
@@ -128,7 +168,7 @@ final class TranscriptionSettings {
}
private func currentState(for locale: Locale) async -> AssetState {
switch await AssetInventory.status(forModules: [Self.transcriber(for: locale)]) {
switch await AssetInventory.status(forModules: [module(for: locale)]) {
case .unsupported: return .unsupported
case .supported: return .notInstalled
case .downloading: return .downloading(nil)
@@ -171,7 +211,7 @@ final class TranscriptionSettings {
private func installIfNeeded(_ locale: Locale) async throws {
let id = Self.canonicalID(locale)
let transcriber = Self.transcriber(for: locale)
let transcriber = module(for: locale)
guard await AssetInventory.status(forModules: [transcriber]) != .installed else {
assetStates[id] = .installed
return
@@ -202,23 +242,61 @@ final class TranscriptionSettings {
func enabledInstalledLocales() async -> [Locale] {
var installed: [Locale] = []
for locale in enabledLocales
where await AssetInventory.status(forModules: [Self.transcriber(for: locale)]) == .installed {
where await AssetInventory.status(forModules: [module(for: locale)]) == .installed {
installed.append(locale)
}
return installed
}
// MARK: - Helpers
// MARK: - Engine routing
/// 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: []
)
/// Which module transcribes `locale`: `.dictation` only for a locale the
/// dictation engine covers and `SpeechTranscriber` does not; `.speech`
/// otherwise. This both prefers `SpeechTranscriber` when both cover a locale
/// and defaults to it before `prepare()` has populated the sets (empty sets
/// `.speech` everywhere, the historical behavior) so a transcription that
/// races startup still routes sanely rather than sending English to dictation.
/// A truly unsupported locale reports `.unsupported` under either engine.
func engine(for locale: Locale) -> EngineKind {
let id = Self.canonicalID(locale)
if dictationLocaleIDs.contains(id), !speechLocaleIDs.contains(id) { return .dictation }
return .speech
}
/// The analyzer module for a locale, built with its resolved engine. Used for
/// asset queries (confidence omitted, which doesn't affect asset status) and,
/// with `requestingConfidence: true`, for the transcription passes.
func module(for locale: Locale, requestingConfidence: Bool = false) -> any SpeechModule {
Self.module(for: locale, engine: engine(for: locale), requestingConfidence: requestingConfidence)
}
/// A locale's analyzer module for a given engine. Pure, so `TranscriptionService`
/// can build the same module the settings screen queries. `requestingConfidence`
/// asks the engine for the `.transcriptionConfidence` attribute honored where
/// the engine emits it (`SpeechTranscriber` always; `DictationTranscriber` may
/// leave runs unattributed, which `LanguagePicker` degrades gracefully).
static func module(
for locale: Locale,
engine: EngineKind,
requestingConfidence: Bool = false
) -> any SpeechModule {
switch engine {
case .speech:
return SpeechTranscriber(
locale: locale,
transcriptionOptions: [],
reportingOptions: [],
attributeOptions: requestingConfidence ? [.transcriptionConfidence] : []
)
case .dictation:
return DictationTranscriber(
locale: locale,
contentHints: [],
transcriptionOptions: [],
reportingOptions: [],
attributeOptions: requestingConfidence ? [.transcriptionConfidence] : []
)
}
}
private func persist() {