ivan-magda/swift-speech-lanes
On-device, multi-language speech-to-text for Apple platforms. Give it an audio file and an ordered list of locales; it runs one transcription lane per locale on Apple's SpeechAnalyzer stack and returns the best transcript, picked by the engine's own confidence.
Table of Contents
Background
Apple's SpeechAnalyzer (macOS 26 / iOS 26) transcribes on-device with no network and no permission prompt, but it has no audio language detection, and each transcriber covers a single locale. Apple also ships SpeechTranscriber models for only some languages: Russian, for example, has no SpeechTranscriber model and must fall back to DictationTranscriber, the system-dictation engine.
Supporting more than one language takes more than one API call. You have to run each candidate locale yourself, pick the right transcriber for each, provision its model, and then decide which output to trust, because a mismatched-language lane returns confident-looking garbage instead of staying silent.
SpeechLanes does that work for you. It runs one lane per configured locale, chooses SpeechTranscriber where a model exists and DictationTranscriber otherwise, and lets a confidence arbiter pick the winner. SpeechLanes began as the transcription engine of a production Telegram assistant that answers spoken messages in English and Russian.
Features
- Multi-locale. One lane per configured locale, tried in priority order. A lane that matches the audio wins; the rest never run.
- Automatic engine selection.
SpeechTranscriberwhere Apple ships a model for the locale,DictationTranscriberfallback (for exampleru-RU). - Confidence arbitration. A lane above
acceptConfidencewins outright; otherwise the highest-confidence lane above a floor wins; audio matching no configured language fails with a typedlowConfidenceerror instead of returning plausible-looking nonsense. - Resilient. One lane's engine failure never takes down a language that still works.
- Idempotent model provisioning. Reserves, evicts stale reservations, and downloads assets on first use; offline afterwards.
- Any decodable audio. Anything
AVAudioFilecan open: Telegram-shaped Ogg/Opus, MP3, M4A, CAF, WAV.AVAudioFilesniffs the content, so the file extension is irrelevant. - Bounded and cancellable. Optional decoded-duration cap, and the engine honors task cancellation so you can wrap it in a timeout.
- Swift 6, strict concurrency. A
Sendableactor behind a small injectable protocol, with typed throws. Zero third-party dependencies.
Requirements
- iOS 26.0+ / macOS 26.0+ / visionOS 26.0+
- Swift 6.2+ / Xcode 26+
- Runs on-device. The only network access is the one-time model download the first time you use a locale; the OS shares those models system-wide afterwards.
Installation
Xcode
- Open File → Add Package Dependencies…
- Enter the URL:
https://github.com/ivan-magda/swift-speech-lanes.git - Choose the
SpeechLaneslibrary and add it to your target.
Package.swift
Add the package to your dependencies:
dependencies: [
.package(
url: "https://github.com/ivan-magda/swift-speech-lanes.git",
from: "1.0.0"
)
]Then add the product to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "SpeechLanes", package: "swift-speech-lanes")
]
)Usage
Basic
Configure one or more locales and hand the transcriber a file URL.
import SpeechLanes
let transcriber = SpeechLaneTranscriber(
configuration: Configuration(localeIdentifiers: ["en-US"])
)
let result = try await transcriber.transcribe(audioFileAt: audioURL)
print(result.text)Multiple locales
List every language the audio might be in, most likely first. Each becomes a lane; the first lane whose confidence clears acceptConfidence wins without running the rest, so ordering hints at the likely match without constraining it.
let transcriber = SpeechLaneTranscriber(
configuration: Configuration(localeIdentifiers: ["en-US", "ru-RU"])
)
let result = try await transcriber.transcribe(audioFileAt: audioURL)You can pass Locale values directly instead of identifier strings:
Configuration(locales: [Locale(identifier: "en-US"), Locale(identifier: "ru-RU")])Mixed-language audio
A file that switches languages mid-recording settles on a single winning lane, and that lane transcribes the whole file. Phrases in the other language survive only as well as the winning engine handles them; the ru-RU dictation model, for example, carries embedded English. The arbiter chooses that one lane and rejects confident wrong-language output, rather than stitching transcripts across lanes. The live suite exercises this with a bundled Russian-then-English fixture (voice-note-mixed.oga).
Inspecting the result
TranscriptionResult carries the provenance you need to decide how much to trust the text.
let result = try await transcriber.transcribe(audioFileAt: audioURL)
result.text // "the quick brown fox…"
result.locale // the lane that won, e.g. ru-RU
result.confidence // averaged per-word confidence, or nil if the engine reported none
result.engine // .speech or .dictationHandling failures
Every failure is a typed TranscriptionError, so you can map each to a message.
do {
let result = try await transcriber.transcribe(audioFileAt: audioURL)
handle(result.text)
} catch let error as TranscriptionError {
switch error {
case .lowConfidence:
show("I couldn't make out that audio in any configured language.")
case .audioTooLong:
show("That clip is too long to transcribe.")
case .unavailable, .localeUnsupported, .assetsUnavailable:
show("Transcription isn't available on this device yet.")
case .undecodableAudio:
show("I couldn't decode that audio.")
case .transcriptionFailed, .cancelled:
show("Something went wrong. Please try again.")
}
}Capping audio duration
Set maximumAudioDuration to reject long audio before the engine runs. The check reads the decoded length, so a forged container header can't spoof it.
Configuration(localeIdentifiers: ["en-US"], maximumAudioDuration: .seconds(600))Bounding with a timeout
The engine has no built-in deadline, but it honors task cancellation. A cancelled task abandons a wedged analyzer and throws .cancelled. Wrap the call in a task group to impose one:
extension SpeechLaneTranscriber {
func transcribe(
audioFileAt url: URL,
timeout: Duration
) async throws -> TranscriptionResult {
try await withThrowingTaskGroup(of: TranscriptionResult.self) { group in
group.addTask { try await self.transcribe(audioFileAt: url) }
group.addTask {
try await Task.sleep(for: timeout)
throw TranscriptionError.cancelled
}
defer { group.cancelAll() }
guard let winner = try await group.next() else {
throw TranscriptionError.cancelled
}
return winner
}
}
}Testing with a fake
SpeechLaneTranscriber conforms to SpeechTranscribing, so app code can depend on the protocol and inject a fake: no speech stack, no audio, no async model download in your unit tests.
struct StubTranscriber: SpeechTranscribing {
let result: TranscriptionResult
func transcribe(audioFileAt url: URL) async throws(TranscriptionError) -> TranscriptionResult {
result
}
}How It Works
- Resolve. Each configured locale becomes a lane.
LaneResolvermatches an exact BCP-47 tag first, then widens a partial tag (ru→ru_RU) through the engine's own equivalence, counting the result only when that engine supports it. A locale with aSpeechTranscribermodel becomes a.speechlane; otherwise it falls back to a.dictationlane. - Guard.
DurationGuardopens the audio once withAVAudioFileand checks its decoded duration againstmaximumAudioDuration. - Run. Lanes run in priority order. Each provisions its model (idempotently), analyzes the file through
SpeechAnalyzer, and averages the engine's per-word confidence. A lane clearingacceptConfidence(default0.6) returns immediately. - Arbitrate. If no lane cleared the accept threshold, the highest-confidence lane above
floorConfidence(default0.3) wins. If every lane scored below the floor, the result islowConfidence. The defaults sit inside the measured separation between a matching language (≥ 0.84 average) and a mismatched one (≤ 0.21). The arbiter remembers a lane that failed outright and surfaces it only when no lane produced a usable transcript, so a generic "couldn't make it out" never hides a real, actionable fault.
Project Structure
Sources/SpeechLanes/
├── Transcriber/
│ ├── SpeechLaneTranscriber.swift // Public actor: orchestrates the lanes, settles the winner
│ └── SpeechTranscribing.swift // The injectable protocol seam
├── Model/
│ ├── Configuration.swift // Locales + confidence thresholds + duration cap
│ ├── TranscriptionResult.swift // Winning text, locale, confidence, engine
│ ├── TranscriberEngine.swift // .speech | .dictation
│ ├── TranscriptionError.swift // Typed failure modes
│ └── ScoredTranscript.swift // One lane's scored output
├── Lanes/
│ ├── Lane.swift // A resolved locale bound to its engine
│ ├── LaneResolver.swift // Locale → lane (exact tag, then equivalence widening)
│ ├── LaneExecutor.swift // Runs a lane through SpeechAnalyzer
│ ├── LaneArbiter.swift // Pure confidence-based winner selection
│ ├── AssetProvisioner.swift // Idempotent model reserve / evict / install
│ └── DurationGuard.swift // Ground-truth decoded-duration cap
└── Extensions/
└── Locale+BCP47.swift // Folded BCP-47 tag for locale identityContributing
Issues and pull requests are welcome. The package builds and tests with:
swift build
swift testThe deterministic suite runs anywhere the package builds. The end-to-end engine tests are opt-in, since they transcribe on real hardware and may download model assets on first run:
SPEECHLANES_LIVE_TESTS=1 swift testLint with swiftlint --strict before opening a pull request.
License
Released under the MIT License. See LICENSE for the full text.
Package Metadata
Repository: ivan-magda/swift-speech-lanes
Default branch: main
README: README.md