CapScribe transcribes video on the device with iOS 26's SpeechAnalyzer and then burns the captions into the file. Early versions asked one question before starting: does SpeechTranscriber have an equivalent for this locale? If the answer was nil, the app said "this language isn't supported for on-device transcription" and stopped.
For a lot of people that message was false. SpeechTranscriber, the new high-quality model, covers roughly ten languages. iOS 26 also ships DictationTranscriber, the dictation model exposed as a module for the same SpeechAnalyzer, and its list is much longer: around thirty more locales. The OS had a model for these users and we were sending them away.
The old check
guard let supportedLocale = await SpeechTranscriber.supportedLocale(equivalentTo: locale) else {
throw EngineError.unsupportedLanguage
}
One model, one question, and the only other outcome was an error.
Resolve a tier instead of a yes or no
The engine now picks a tier. It tries the modern model first, then the dictation model, and only reports "unsupported" when both say no:
enum TranscriptionEngineTier: String, Sendable {
case modern // SpeechTranscriber
case basic // DictationTranscriber fallback
}
func resolveTier(for locale: Locale) async -> (tier: TranscriptionEngineTier, supportedLocale: Locale)? {
if let modern = await dependencies.modernSupportedLocale(locale) {
return (.modern, modern)
}
if let basic = await dependencies.basicSupportedLocale(locale) {
return (.basic, basic)
}
return nil
}
The two branches build different transcribers, but everything after that is shared. You ask AssetInventory for an installation request, download the model if there is one to download, hand the module to SpeechAnalyzer, and read results:
let transcriber = DictationTranscriber(
locale: supportedLocale,
contentHints: [],
transcriptionOptions: [.punctuation],
reportingOptions: [],
attributeOptions: [.audioTimeRange, .transcriptionConfidence]
)
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
try await request.downloadAndInstall()
}
Two details are easy to get wrong here.
supportedLocale(equivalentTo:) gives back the locale the model actually supports, and that isn't always the one you passed in. So the "is the model already installed?" check compares the returned locale against installedLocales, not the user's raw setting.
Word timings are the other one. The dictation path asks for .audioTimeRange, but whether each word comes back with a range depends on the model. Our collector skips words without a usable range, and if a segment ends up with none, it stores nil for that segment's word timings. Downstream code then sees "no word timings" rather than numbers we made up.
Tell people which model they got
A silent fallback would just be a quieter version of the same lie, so the tier shows up in two places.
The language menu lists modern languages first, then the dictation-only ones with a "(basic quality)" suffix. A language that both models support appears once, in the modern group.
After a basic-tier run, the editor shows a one-line banner: "This language uses basic recognition with lower accuracy." The engine reports the tier through a callback when it starts transcribing, and the view model stores it. That way the banner reflects what actually ran, not what the menu promised.
Testing without a real device
Which locales each model supports is an OS fact you don't control in a test run. So the engine takes its lookups as injected closures, and a fallback test can fake a world where the modern model knows nothing:
let deps = TranscriptionEngine.Dependencies(
isAvailable: { true },
modernSupportedLocale: { _ in nil },
basicSupportedLocale: { $0.identifier.hasPrefix("el") ? Locale(identifier: "el-GR") : nil }
)
let result = await TranscriptionEngine(dependencies: deps).resolveTier(for: Locale(identifier: "el-GR"))
XCTAssertEqual(result?.tier, .basic)
The same file covers the modern model winning when both answer, both failing, the device being unavailable, and resource status across the two tiers.
What it costs
Some users now get captions that need more fixing than before, which is the whole reason the banner exists. The menu got longer, and its order turned into a product decision we have to keep making. I'd still rather hand someone lower-accuracy captions with a clear warning than tell them their language doesn't exist when the OS can handle it.
CapScribe on the App Store: https://apps.apple.com/app/id6801041856?utm_source=devto
Disclosure: I build CapScribe.
Top comments (0)