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
+50 -12
View File
@@ -172,9 +172,13 @@ final class TranscriptionService {
var scored: [LanguageCandidate] = []
for locale in candidates {
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(
localeID: TranscriptionSettings.canonicalID(locale),
meanConfidence: LanguagePicker.meanConfidence(of: transcript),
meanConfidence: LanguagePicker.rankingConfidence(of: transcript),
transcriptLength: transcript.characters.count
))
}
@@ -207,17 +211,51 @@ final class TranscriptionService {
// 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.
/// transcript (carrying per-run confidence where the engine emits it). Routes
/// 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 {
let transcriber = SpeechTranscriber(
locale: locale,
transcriptionOptions: [],
reportingOptions: [],
attributeOptions: [.transcriptionConfidence]
)
switch settings.engine(for: locale) {
case .speech:
let transcriber = SpeechTranscriber(
locale: locale,
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(
modules: [transcriber],
modules: [module],
options: SpeechAnalyzer.Options(priority: .utility, modelRetention: .whileInUse)
)
let file = try AVAudioFile(forReading: fileURL)
@@ -226,8 +264,8 @@ final class TranscriptionService {
// 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)
for try await result in results where result.isFinal {
full.append(textOf(result))
}
return full
}