Transcribe audio and video to timestamped text entirely on-device. No network access, no third-party dependencies, no API keys, no per-minute cost — pure Apple SpeechAnalyzer.
Built on Apple's SpeechAnalyzer / SpeechTranscriber (macOS 26 / iOS 26). Accepts a local audio file, a remote audio URL, or a local/remote video URL, and returns a structured transcript with sentence-level timestamps and SRT/VTT/JSON export.
- 🎯 Simple API — transcribe with a single async call
- 🎙️ On-device — Apple
SpeechAnalyzer, completely free, no data leaves the machine - 🧠 Two engines, one API — Apple
SpeechAnalyzer(fastest, zero-dependency) or Whisper via WhisperKit (accuracy + 100 languages); both return the sameTranscript - 🔗 Audio or video — local files or remote URLs; video audio tracks are extracted automatically
- 🔴 Live streaming (both engines) — Apple
audioInput+ mic, or Whisper live mic (VAD-gated); interim + final captions - 🗣️ Speaker diarization — optional
Diarizerproduct (SpeakerKit / pyannote) labels who spoke when - 🌐 Translate — Whisper can transcribe-and-translate any language to English in one pass
- 🔤 Custom vocabulary — bias recognition toward names, jargon, and commands the model would otherwise mis-hear (Apple + Whisper); a real help for proper nouns and strong accents
- ⏱️ Timestamped segments — sentence-level (or word-level) timing for every line
- 💬 Subtitle export — SubRip (
.srt) and WebVTT (.vtt) out of the box - 📦 JSON export —
snake_caseJSON, great for LLM input or storage - 🌍 Locale management — discover supported/installed languages and pre-download models
- 🎤 Microphone helper — built-in
MicrophoneCapturefor live transcription - 📊 Progress reporting — real-time callbacks for UI integration
- 🔒 Sandbox-friendly — temp files only, no security-scoped grants required
- 🍎 Cross-platform — macOS 26+, iOS 26+
- 🔒 Zero dependencies (Apple engine) — the
SpeechTranscriberproduct uses only Speech + AVFoundation; WhisperKit is an opt-in second product
- macOS 26.0+ / iOS 26.0+
- Swift 6.2+
- Xcode 26.0+
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/arraypress/swift-speech-transcriber.git", from: "1.0.0")
]The package vends three library products — depend on whichever you need:
SpeechTranscriber— the AppleSpeechAnalyzerengine. Zero dependencies.WhisperTranscriber— the Whisper engine (pulls in WhisperKit). Returns the sameTranscripttype.Diarizer— speaker diarization (pulls in SpeakerKit). Labels an existingTranscriptwith who spoke.
import SpeechTranscriber
// A local audio file
let transcript = try await SpeechTranscriber.transcribe(audioURL: fileURL)
// Plain text dump (great for LLM input)
print(transcript.plainText)
// Timestamped, one segment per line
for segment in transcript.segments {
print("[\(segment.formattedStart)] \(segment.text)")
}Same call shape, same Transcript out — swap the engine when you want Whisper's
accuracy and 100-language coverage. Models download from Hugging Face on first
use and are cached. Use this as the "engine" toggle in your app's settings.
import WhisperTranscriber
let transcript = try await WhisperTranscriber.transcribe(
audioURL: url,
model: .largeV3Turbo, // .auto picks per device; .tiny … .largeV3
language: nil // nil = auto-detect, or "en", "de", …
)
print(transcript.plainText)
let srt = transcript.srt // same export helpers as the Apple engineA typical settings-driven dispatch:
enum Engine { case apple, whisper }
func transcribe(_ url: URL, using engine: Engine) async throws -> Transcript {
switch engine {
case .apple: return try await SpeechTranscriber.transcribe(audioURL: url)
case .whisper: return try await WhisperTranscriber.transcribe(audioURL: url, model: .largeV3Turbo)
}
}Real-time captions from the mic, Whisper-powered (VAD-gated). Same
LiveTranscriptionEvent shape as the Apple engine's live API.
for try await event in WhisperTranscriber.transcribeLive(model: .small) {
if event.isFinal { transcript += event.text + " " }
else { liveCaption = event.text } // updates as you speak
}// Any language in → English transcript out, in one pass
let english = try await WhisperTranscriber.transcribe(audioURL: url, translate: true)Label an existing transcript (from either engine) with who spoke when.
import Diarizer
let transcript = try await SpeechTranscriber.transcribe(audioURL: url)
let diarized = try await Diarizer.diarize(transcript, audioURL: url)
print(diarized.speakerCount) // e.g. 2
print(diarized.plainText) // "Speaker 1: …\nSpeaker 2: …"
// Or just the raw turns ("who spoke when")
let turns = try await Diarizer.diarize(audioURL: url)// Remote audio — downloaded to a temp file, transcribed, cleaned up
let podcast = try await SpeechTranscriber.transcribe(
audioURL: URL(string: "https://example.com/episode.mp3")!
)
// A video's audio track — extracted, then transcribed
let clip = try await SpeechTranscriber.transcribe(videoURL: videoURL)guard SpeechTranscriber.isAvailable else {
// Present a fallback — this device/OS can't run the on-device engine
return
}let options = TranscriptionOptions(
locale: Locale(identifier: "de-DE"), // any installed speech locale
segmentation: .sentence, // .sentence (default) or .word
vocabulary: ["arraypress", "Kokoro"] // bias toward these terms
)
let transcript = try await SpeechTranscriber.transcribe(audioURL: url, options: options)Give the recognizer a list of terms — names, brand names, jargon, commands — that it would otherwise mis-hear. This is contextual biasing (making those terms more likely), not a hard constraint, and it's especially helpful for proper nouns and strong accents.
// Apple engine — via TranscriptionOptions
let options = TranscriptionOptions(vocabulary: ["arraypress", "Kokoro", "SwiftUI", "FODMAP"])
let transcript = try await SpeechTranscriber.transcribe(audioURL: url, options: options)
// Whisper engine — via the vocabulary parameter
let transcript = try await WhisperTranscriber.transcribe(
audioURL: url,
model: .largeV3Turbo,
vocabulary: ["arraypress", "Kokoro", "SwiftUI", "FODMAP"]
)Each engine maps it to its own mechanism: Apple's SpeechAnalyzer contextual
strings, and Whisper's decoder prompt (the terms are tokenized and seeded
into the prompt). Effectiveness varies by engine — Apple's contextual strings are
a firmer bias; Whisper's prompt is softer but also nudges spelling. (Parakeet does
not yet support custom vocabulary here.)
Feed captured audio (microphone, system audio, or a file read in chunks) and
receive interim (isFinal == false) and final captions as they're recognised:
let mic = try MicrophoneCapture()
for try await event in SpeechTranscriber.transcribe(audioInput: mic.stream()) {
if event.isFinal {
transcript += event.text + " "
} else {
liveCaption = event.text // updates rapidly as you speak
}
}
mic.stop()Any source works — just yield AudioInput values into an AsyncStream:
let (stream, continuation) = AsyncStream<AudioInput>.makeStream()
// ...from your own AVAudioEngine tap, ScreenCaptureKit, or file reads...
continuation.yield(AudioInput(pcmBuffer))
continuation.finish()
for try await event in SpeechTranscriber.transcribe(audioInput: stream) { ... }Microphone access requires
NSMicrophoneUsageDescription(and, on macOS, the audio-input entitlement). The OS prompts on first use.
Discover supported and installed languages, and pre-download a model so the first transcription doesn't pay the download cost:
let supported = await SpeechTranscriber.supportedLocales // [Locale]
let installed = await SpeechTranscriber.installedLocales
if await SpeechTranscriber.isSupported(locale),
await !SpeechTranscriber.isInstalled(locale) {
try await SpeechTranscriber.prepare(locale: locale) { progress in
print(progress.statusText)
}
}let transcript = try await SpeechTranscriber.transcribe(videoURL: videoURL)
try transcript.srt.write(to: srtURL, atomically: true, encoding: .utf8)
try transcript.vtt.write(to: vttURL, atomically: true, encoding: .utf8)let transcript = try await SpeechTranscriber.transcribe(audioURL: url)
let json = try transcript.jsonString() // pretty, snake_case
let data = try transcript.jsonData() // for APIs / files
let compact = try transcript.jsonString(prettyPrinted: false)let transcript = try await SpeechTranscriber.transcribe(audioURL: url) { progress in
print(progress.statusText) // "Transcribing: 42%"
progressBar.progress = progress.fraction // 0.0–1.0
}do {
let transcript = try await SpeechTranscriber.transcribe(audioURL: url)
} catch TranscriptionError.unavailable {
print("On-device transcription requires macOS 26 / iOS 26")
} catch TranscriptionError.noAudioTrack {
print("That video has no audio")
} catch TranscriptionError.localeNotSupported(let id) {
print("No speech model available for \(id)")
} catch {
print("Error: \(error.localizedDescription)")
}SpeechTranscriber.transcribe(audioURL:/videoURL:)
├─ Fetch → download remote audio, or extract a video's audio track to .m4a
├─ Reserve → reserve the locale and install its speech model on first use
├─ Analyze → SpeechAnalyzer streams recognized text via AsyncSequence
├─ Segment → NLTokenizer splits text; audioTimeRange gives per-segment timing
└─ Transcript → segments + plainText + SRT/VTT/JSON
All transcription happens on-device. The first run for a locale downloads Apple's speech model; every run after that is fully offline. Temporary files are written to the system temporary directory, so the library works inside the App Sandbox without any security-scoped bookmarks.
| Property | Type | Description |
|---|---|---|
segments |
[TranscriptSegment] |
Timed segments in chronological order |
audioDuration |
TimeInterval |
Total audio duration in seconds |
transcriptionTime |
TimeInterval |
Wall-clock time spent transcribing |
plainText |
String |
All segment text joined with spaces |
timestampedText |
String |
"[0:00] Hello everyone", one per line |
wordCount |
Int |
Total words across all segments |
speedFactor |
Double |
Transcription speed vs real time (e.g. 30×) |
srt / vtt |
String |
SubRip / WebVTT subtitle documents |
- Podcasts — turn an episode URL into a searchable, quotable transcript
- Interviews — private, on-device transcription for sensitive audio
- Subtitles — batch SRT/VTT for a back-catalogue of videos
- Meeting recordings — recorded calls into editable notes
- Content repurposing — pull quotes and chapters out of long-form audio
swift testUnit tests cover time formatting, segment and transcript models, SRT/VTT/JSON export, options, progress, error handling, and input validation. The integration test requires an example.m4a in the test resources directory and an available on-device engine — it skips gracefully when either is missing.
MIT License — see LICENSE file for details.
Created by David Sherlock (ArrayPress) in 2026.