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:
@@ -1,5 +1,11 @@
|
|||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
|
Transcription now supports many more languages, including Russian, Ukrainian, and Polish
|
||||||
|
|
||||||
|
New notes start from two big buttons right above the list — one for text, one for voice
|
||||||
|
|
||||||
|
On iPhone, search now lives in the toolbar at the bottom of the screen
|
||||||
|
|
||||||
Record voice notes that are transcribed to text right on your device, with the original audio kept alongside the note for playback
|
Record voice notes that are transcribed to text right on your device, with the original audio kept alongside the note for playback
|
||||||
|
|
||||||
Choose the languages you speak in Settings and transcription picks the right one automatically, or re-transcribe any note in another language
|
Choose the languages you speak in Settings and transcription picks the right one automatically, or re-transcribe any note in another language
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ struct LanguageCandidate: Equatable {
|
|||||||
/// side-effect-free is what makes the (undocumented, cross-locale) confidence
|
/// side-effect-free is what makes the (undocumented, cross-locale) confidence
|
||||||
/// calibration unit-testable.
|
/// calibration unit-testable.
|
||||||
enum LanguagePicker {
|
enum LanguagePicker {
|
||||||
|
/// The confidence assigned to a transcript that carried text but no
|
||||||
|
/// `transcriptionConfidence` attribute at all — the graceful-degradation case
|
||||||
|
/// for `DictationTranscriber`, which need not emit per-run confidence. A
|
||||||
|
/// mid-scale value so such a candidate is ranked by transcript length against
|
||||||
|
/// its peers rather than being zeroed out and always losing to a confidence-
|
||||||
|
/// reporting engine. Two confidence-less candidates then tie here and fall
|
||||||
|
/// through to the length tie-break in `pick`.
|
||||||
|
static let neutralConfidence = 0.5
|
||||||
|
|
||||||
/// Character-count-weighted mean confidence over an attributed transcript.
|
/// Character-count-weighted mean confidence over an attributed transcript.
|
||||||
/// Only runs that actually carry a `transcriptionConfidence` attribute
|
/// Only runs that actually carry a `transcriptionConfidence` attribute
|
||||||
/// contribute, weighted by their character length; a transcript with no
|
/// contribute, weighted by their character length; a transcript with no
|
||||||
@@ -37,6 +46,22 @@ enum LanguagePicker {
|
|||||||
return totalCharacters > 0 ? weighted / Double(totalCharacters) : 0
|
return totalCharacters > 0 ? weighted / Double(totalCharacters) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any run in the transcript carries a `transcriptionConfidence`
|
||||||
|
/// attribute — i.e. whether `meanConfidence` measured anything real.
|
||||||
|
static func hasConfidence(of attributed: AttributedString) -> Bool {
|
||||||
|
attributed.runs.contains { $0.transcriptionConfidence != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The confidence used to *rank* a candidate. Prefers the real weighted mean;
|
||||||
|
/// when the transcript has text but no confidence attribute anywhere (an engine
|
||||||
|
/// that doesn't emit it), falls back to `neutralConfidence` so the candidate
|
||||||
|
/// competes on length instead of being zeroed. An empty transcript scores 0 —
|
||||||
|
/// it can never win against real text.
|
||||||
|
static func rankingConfidence(of attributed: AttributedString) -> Double {
|
||||||
|
if hasConfidence(of: attributed) { return meanConfidence(of: attributed) }
|
||||||
|
return attributed.characters.isEmpty ? 0 : neutralConfidence
|
||||||
|
}
|
||||||
|
|
||||||
/// The best candidate, or nil when given nothing. Argmax on `meanConfidence`;
|
/// The best candidate, or nil when given nothing. Argmax on `meanConfidence`;
|
||||||
/// a zero-length transcript can only win if every candidate is empty. Ties
|
/// a zero-length transcript can only win if every candidate is empty. Ties
|
||||||
/// break deterministically — higher confidence, then longer transcript, then
|
/// break deterministically — higher confidence, then longer transcript, then
|
||||||
|
|||||||
@@ -172,9 +172,13 @@ final class TranscriptionService {
|
|||||||
var scored: [LanguageCandidate] = []
|
var scored: [LanguageCandidate] = []
|
||||||
for locale in candidates {
|
for locale in candidates {
|
||||||
let transcript = (try? await runTranscription(fileURL: sample.url, locale: locale)) ?? AttributedString()
|
let transcript = (try? await runTranscription(fileURL: sample.url, locale: locale)) ?? AttributedString()
|
||||||
|
// `rankingConfidence`, not `meanConfidence`: a `DictationTranscriber`
|
||||||
|
// locale may leave runs unattributed, so an unscored-but-non-empty
|
||||||
|
// transcript gets a neutral score and competes on length rather than
|
||||||
|
// losing outright to a confidence-reporting `SpeechTranscriber` locale.
|
||||||
scored.append(LanguageCandidate(
|
scored.append(LanguageCandidate(
|
||||||
localeID: TranscriptionSettings.canonicalID(locale),
|
localeID: TranscriptionSettings.canonicalID(locale),
|
||||||
meanConfidence: LanguagePicker.meanConfidence(of: transcript),
|
meanConfidence: LanguagePicker.rankingConfidence(of: transcript),
|
||||||
transcriptLength: transcript.characters.count
|
transcriptLength: transcript.characters.count
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -207,17 +211,51 @@ final class TranscriptionService {
|
|||||||
// MARK: - Analyzer
|
// MARK: - Analyzer
|
||||||
|
|
||||||
/// Transcribe a whole file in one locale and return the finalized attributed
|
/// Transcribe a whole file in one locale and return the finalized attributed
|
||||||
/// transcript (carrying per-run confidence). Follows the documented pattern:
|
/// transcript (carrying per-run confidence where the engine emits it). Routes
|
||||||
/// start a collector over `transcriber.results`, feed the file, finalize.
|
/// to whichever engine supports the locale — `SpeechTranscriber` or, for its
|
||||||
|
/// dictation-only locales (ru-RU, uk-UA, pl-PL …), `DictationTranscriber` —
|
||||||
|
/// via `TranscriptionSettings.engine(for:)`. Both request confidence; the two
|
||||||
|
/// `Result` types share `isFinal`/`text` but only concretely, so the engine
|
||||||
|
/// branch supplies the module and its result stream to a shared `analyze`.
|
||||||
private func runTranscription(fileURL: URL, locale: Locale) async throws -> AttributedString {
|
private func runTranscription(fileURL: URL, locale: Locale) async throws -> AttributedString {
|
||||||
let transcriber = SpeechTranscriber(
|
switch settings.engine(for: locale) {
|
||||||
locale: locale,
|
case .speech:
|
||||||
transcriptionOptions: [],
|
let transcriber = SpeechTranscriber(
|
||||||
reportingOptions: [],
|
locale: locale,
|
||||||
attributeOptions: [.transcriptionConfidence]
|
transcriptionOptions: [],
|
||||||
)
|
reportingOptions: [],
|
||||||
|
attributeOptions: [.transcriptionConfidence]
|
||||||
|
)
|
||||||
|
return try await analyze(module: transcriber, results: transcriber.results, fileURL: fileURL) { $0.text }
|
||||||
|
case .dictation:
|
||||||
|
let transcriber = DictationTranscriber(
|
||||||
|
locale: locale,
|
||||||
|
contentHints: [],
|
||||||
|
transcriptionOptions: [],
|
||||||
|
reportingOptions: [],
|
||||||
|
attributeOptions: [.transcriptionConfidence]
|
||||||
|
)
|
||||||
|
return try await analyze(module: transcriber, results: transcriber.results, fileURL: fileURL) { $0.text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive `SpeechAnalyzer` over the whole file with a single module and fold its
|
||||||
|
/// finalized results into one transcript. Generic over the module's result
|
||||||
|
/// stream so one pipeline serves both engines (their `Result`s share `isFinal`
|
||||||
|
/// via `SpeechModuleResult` but expose `text` only concretely — hence `textOf`).
|
||||||
|
/// Follows the documented pattern: start a collector over `results`, feed the
|
||||||
|
/// file, finalize.
|
||||||
|
private func analyze<Results>(
|
||||||
|
module: any SpeechModule,
|
||||||
|
results: Results,
|
||||||
|
fileURL: URL,
|
||||||
|
textOf: @escaping @Sendable (Results.Element) -> AttributedString
|
||||||
|
) async throws -> AttributedString
|
||||||
|
where Results: AsyncSequence & Sendable,
|
||||||
|
Results.Element: SpeechModuleResult & Sendable,
|
||||||
|
Results.Failure == any Error {
|
||||||
let analyzer = SpeechAnalyzer(
|
let analyzer = SpeechAnalyzer(
|
||||||
modules: [transcriber],
|
modules: [module],
|
||||||
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
|
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
|
||||||
)
|
)
|
||||||
let file = try AVAudioFile(forReading: fileURL)
|
let file = try AVAudioFile(forReading: fileURL)
|
||||||
@@ -226,8 +264,8 @@ final class TranscriptionService {
|
|||||||
// analyzer finishes, ending the loop.
|
// analyzer finishes, ending the loop.
|
||||||
let collector = Task {
|
let collector = Task {
|
||||||
var full = AttributedString()
|
var full = AttributedString()
|
||||||
for try await result in transcriber.results where result.isFinal {
|
for try await result in results where result.isFinal {
|
||||||
full.append(result.text)
|
full.append(textOf(result))
|
||||||
}
|
}
|
||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,24 @@ import Speech
|
|||||||
/// *reserves* it with `AssetInventory` — which is both what keeps its model from
|
/// *reserves* it with `AssetInventory` — which is both what keeps its model from
|
||||||
/// being purged and what the system caps at `AssetInventory.maximumReservedLocales`
|
/// 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.
|
/// — 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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TranscriptionSettings {
|
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 enabledKey = "Notes.enabledTranscriptionLocales"
|
||||||
private static let seededKey = "Notes.transcriptionLocalesSeeded"
|
private static let seededKey = "Notes.transcriptionLocalesSeeded"
|
||||||
|
|
||||||
@@ -24,10 +39,20 @@ final class TranscriptionSettings {
|
|||||||
/// Canonical BCP-47 ids of the enabled languages, persisted in UserDefaults.
|
/// Canonical BCP-47 ids of the enabled languages, persisted in UserDefaults.
|
||||||
private(set) var enabledLocaleIDs: [String]
|
private(set) var enabledLocaleIDs: [String]
|
||||||
|
|
||||||
/// Every locale `SpeechTranscriber` can transcribe (36+), loaded once by
|
/// Every locale either engine can transcribe — the union of `SpeechTranscriber`
|
||||||
/// `prepare()`. Empty until then.
|
/// and `DictationTranscriber` coverage, de-duplicated by canonical id and
|
||||||
|
/// loaded once by `prepare()`. Empty until then.
|
||||||
private(set) var supportedLocales: [Locale] = []
|
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
|
/// Runtime cap on how many languages can be enabled at once — read from the
|
||||||
/// system, not hard-coded, because it can change.
|
/// system, not hard-coded, because it can change.
|
||||||
private(set) var maximumReserved = 0
|
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.
|
/// and cheap to call again — it re-fetches the list but only seeds once.
|
||||||
func prepare() async {
|
func prepare() async {
|
||||||
maximumReserved = AssetInventory.maximumReservedLocales
|
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) }
|
.sorted { Self.displayName(for: $0) < Self.displayName(for: $1) }
|
||||||
|
|
||||||
if !defaults.bool(forKey: Self.seededKey) {
|
if !defaults.bool(forKey: Self.seededKey) {
|
||||||
@@ -128,7 +168,7 @@ final class TranscriptionSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentState(for locale: Locale) async -> AssetState {
|
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 .unsupported: return .unsupported
|
||||||
case .supported: return .notInstalled
|
case .supported: return .notInstalled
|
||||||
case .downloading: return .downloading(nil)
|
case .downloading: return .downloading(nil)
|
||||||
@@ -171,7 +211,7 @@ final class TranscriptionSettings {
|
|||||||
|
|
||||||
private func installIfNeeded(_ locale: Locale) async throws {
|
private func installIfNeeded(_ locale: Locale) async throws {
|
||||||
let id = Self.canonicalID(locale)
|
let id = Self.canonicalID(locale)
|
||||||
let transcriber = Self.transcriber(for: locale)
|
let transcriber = module(for: locale)
|
||||||
guard await AssetInventory.status(forModules: [transcriber]) != .installed else {
|
guard await AssetInventory.status(forModules: [transcriber]) != .installed else {
|
||||||
assetStates[id] = .installed
|
assetStates[id] = .installed
|
||||||
return
|
return
|
||||||
@@ -202,23 +242,61 @@ final class TranscriptionSettings {
|
|||||||
func enabledInstalledLocales() async -> [Locale] {
|
func enabledInstalledLocales() async -> [Locale] {
|
||||||
var installed: [Locale] = []
|
var installed: [Locale] = []
|
||||||
for locale in enabledLocales
|
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)
|
installed.append(locale)
|
||||||
}
|
}
|
||||||
return installed
|
return installed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Engine routing
|
||||||
|
|
||||||
/// A minimal transcriber for a locale, used purely for asset queries. The
|
/// Which module transcribes `locale`: `.dictation` only for a locale the
|
||||||
/// attribute options don't affect asset status, so they're omitted here.
|
/// dictation engine covers and `SpeechTranscriber` does not; `.speech`
|
||||||
static func transcriber(for locale: Locale) -> SpeechTranscriber {
|
/// otherwise. This both prefers `SpeechTranscriber` when both cover a locale
|
||||||
SpeechTranscriber(
|
/// and defaults to it before `prepare()` has populated the sets (empty sets ⇒
|
||||||
locale: locale,
|
/// `.speech` everywhere, the historical behavior) — so a transcription that
|
||||||
transcriptionOptions: [],
|
/// races startup still routes sanely rather than sending English to dictation.
|
||||||
reportingOptions: [],
|
/// A truly unsupported locale reports `.unsupported` under either engine.
|
||||||
attributeOptions: []
|
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() {
|
private func persist() {
|
||||||
|
|||||||
@@ -16,57 +16,20 @@ struct NotesListView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
VStack(spacing: 0) {
|
||||||
if let error = syncEngine.lastSyncError {
|
captureButtons
|
||||||
Section {
|
list
|
||||||
Label(error, systemImage: "exclamationmark.icloud")
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if searchText.isEmpty, !relevantNotes.isEmpty {
|
|
||||||
Section("Right here, right now") {
|
|
||||||
ForEach(relevantNotes) { note in
|
|
||||||
row(note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(searchText.isEmpty ? "All notes" : "Results") {
|
|
||||||
ForEach(filteredNotes) { note in
|
|
||||||
row(note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Notes")
|
.navigationTitle("Notes")
|
||||||
|
// iOS 26: attached at the NavigationStack root inside a TabView, this
|
||||||
|
// renders as the Liquid-Glass toolbar search accessory (bottom, in the
|
||||||
|
// tab bar). `.searchToolbarBehavior(.minimize)` collapses it to the
|
||||||
|
// magnifier capsule until tapped — a modifier that only exists on iOS,
|
||||||
|
// hence the guard; macOS keeps the standard toolbar search field.
|
||||||
.searchable(text: $searchText, prompt: "Search notes and tags")
|
.searchable(text: $searchText, prompt: "Search notes and tags")
|
||||||
.overlay {
|
#if os(iOS)
|
||||||
if notes.isEmpty {
|
.searchToolbarBehavior(.minimize)
|
||||||
ContentUnavailableView(
|
#endif
|
||||||
"No notes yet",
|
|
||||||
systemImage: "square.and.pencil",
|
|
||||||
description: Text("Jot something down — Notes remembers where and when, so it can resurface at the right moment.")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
recording = true
|
|
||||||
} label: {
|
|
||||||
Label("Record Note", systemImage: "mic")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
composing = true
|
|
||||||
} label: {
|
|
||||||
Label("New Note", systemImage: "square.and.pencil")
|
|
||||||
}
|
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $composing) {
|
.sheet(isPresented: $composing) {
|
||||||
NoteEditorView(mode: .create)
|
NoteEditorView(mode: .create)
|
||||||
}
|
}
|
||||||
@@ -79,6 +42,68 @@ struct NotesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two prominent capture actions, side by side above the list — the
|
||||||
|
/// primary way to start a note, promoted out of the toolbar. Glass-prominent
|
||||||
|
/// capsules inherit the app's red accent; each fills half the width.
|
||||||
|
private var captureButtons: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
composing = true
|
||||||
|
} label: {
|
||||||
|
Label("Text", systemImage: "square.and.pencil")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
recording = true
|
||||||
|
} label: {
|
||||||
|
Label("Audio", systemImage: "mic.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var list: some View {
|
||||||
|
List {
|
||||||
|
if let error = syncEngine.lastSyncError {
|
||||||
|
Section {
|
||||||
|
Label(error, systemImage: "exclamationmark.icloud")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchText.isEmpty, !relevantNotes.isEmpty {
|
||||||
|
Section("Right here, right now") {
|
||||||
|
ForEach(relevantNotes) { note in
|
||||||
|
row(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(searchText.isEmpty ? "All notes" : "Results") {
|
||||||
|
ForEach(filteredNotes) { note in
|
||||||
|
row(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if notes.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No notes yet",
|
||||||
|
systemImage: "square.and.pencil",
|
||||||
|
description: Text("Jot something down — Notes remembers where and when, so it can resurface at the right moment.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func row(_ note: NoteEntity) -> some View {
|
private func row(_ note: NoteEntity) -> some View {
|
||||||
Button {
|
Button {
|
||||||
editingNote = note
|
editingNote = note
|
||||||
|
|||||||
@@ -36,6 +36,71 @@ struct LanguagePickerTests {
|
|||||||
#expect(LanguagePicker.meanConfidence(of: transcript([("no attributes", nil)])) == 0)
|
#expect(LanguagePicker.meanConfidence(of: transcript([("no attributes", nil)])) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Ranking confidence (mixed-engine graceful degradation)
|
||||||
|
|
||||||
|
@Test func hasConfidenceDetectsAttributedRuns() {
|
||||||
|
#expect(LanguagePicker.hasConfidence(of: transcript([("sure", 0.9)])))
|
||||||
|
#expect(LanguagePicker.hasConfidence(of: transcript([("a", nil), ("b", 0.2)])))
|
||||||
|
#expect(!LanguagePicker.hasConfidence(of: transcript([("none", nil)])))
|
||||||
|
#expect(!LanguagePicker.hasConfidence(of: AttributedString("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rankingConfidenceUsesRealMeanWhenPresent() {
|
||||||
|
// With any confidence attribute, ranking == the character-weighted mean.
|
||||||
|
let attributed = transcript([("hello", 0.9), (" world", 0.4)])
|
||||||
|
#expect(abs(LanguagePicker.rankingConfidence(of: attributed)
|
||||||
|
- LanguagePicker.meanConfidence(of: attributed)) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rankingConfidenceFallsBackToNeutralForUnattributedText() {
|
||||||
|
// A DictationTranscriber transcript with text but no confidence attribute
|
||||||
|
// scores the documented neutral value, not 0, so it can still compete.
|
||||||
|
let dictation = transcript([("привет мир", nil)])
|
||||||
|
#expect(LanguagePicker.rankingConfidence(of: dictation) == LanguagePicker.neutralConfidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rankingConfidenceOfEmptyIsZero() {
|
||||||
|
#expect(LanguagePicker.rankingConfidence(of: AttributedString("")) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func neutralDictationBeatsLowConfidenceSpeech() {
|
||||||
|
// Audio is Russian: the dictation locale produces good (neutral-scored)
|
||||||
|
// text; the mismatched speech locale produces low-confidence garbage.
|
||||||
|
let winner = LanguagePicker.pick(from: [
|
||||||
|
LanguageCandidate(localeID: "en-US", meanConfidence: 0.2, transcriptLength: 12),
|
||||||
|
LanguageCandidate(localeID: "ru-RU",
|
||||||
|
meanConfidence: LanguagePicker.neutralConfidence,
|
||||||
|
transcriptLength: 18),
|
||||||
|
])
|
||||||
|
#expect(winner?.localeID == "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func highConfidenceSpeechBeatsNeutralDictation() {
|
||||||
|
// Audio is English: the speech locale's real high confidence outranks the
|
||||||
|
// mismatched dictation locale's neutral score.
|
||||||
|
let winner = LanguagePicker.pick(from: [
|
||||||
|
LanguageCandidate(localeID: "en-US", meanConfidence: 0.85, transcriptLength: 20),
|
||||||
|
LanguageCandidate(localeID: "ru-RU",
|
||||||
|
meanConfidence: LanguagePicker.neutralConfidence,
|
||||||
|
transcriptLength: 22),
|
||||||
|
])
|
||||||
|
#expect(winner?.localeID == "en-US")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func twoNeutralDictationCandidatesBreakByLength() {
|
||||||
|
// Both engines dictation-only (e.g. ru-RU vs uk-UA), both neutral-scored:
|
||||||
|
// the longer transcript wins the tie-break.
|
||||||
|
let winner = LanguagePicker.pick(from: [
|
||||||
|
LanguageCandidate(localeID: "uk-UA",
|
||||||
|
meanConfidence: LanguagePicker.neutralConfidence,
|
||||||
|
transcriptLength: 8),
|
||||||
|
LanguageCandidate(localeID: "ru-RU",
|
||||||
|
meanConfidence: LanguagePicker.neutralConfidence,
|
||||||
|
transcriptLength: 24),
|
||||||
|
])
|
||||||
|
#expect(winner?.localeID == "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Argmax
|
// MARK: - Argmax
|
||||||
|
|
||||||
@Test func pickChoosesHighestConfidence() {
|
@Test func pickChoosesHighestConfidence() {
|
||||||
|
|||||||
Reference in New Issue
Block a user