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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user