AVSpeechSynthesizer looks trivial from the API surface — instantiate an utterance, hand it to a synthesizer, done. It stays trivial right up until you ship it in a real app with a real UI around it: a pause button that doesn't pause, a voice picker with two hundred entries and no obvious default, a rate slider whose numbers mean nothing to a user. The gap between the demo and a good implementation is entirely in the details nobody puts in the "hello world."
The three lines everyone starts with
import AVFoundation
let synthesizer = AVSpeechSynthesizer()
let utterance = AVSpeechUtterance(string: "Text goes here")
synthesizer.speak(utterance)
This works. It's also missing every control a real feature needs: voice selection, rate, pitch, and any way to know when speech starts or finishes.
Picking a voice that isn't arbitrary
AVSpeechSynthesisVoice.speechVoices() returns every installed voice on the device — on a modern iPhone, that's often 50 or more once you count every language and variant. Don't default to the first one in the array; it's not sorted meaningfully. Default to the voice matching the user's actual device language:
func defaultVoice() -> AVSpeechSynthesisVoice? {
let preferredLanguage = Locale.preferredLanguages.first ?? "en-US"
return AVSpeechSynthesisVoice(language: preferredLanguage)
?? AVSpeechSynthesisVoice(language: "en-US")
}
If you're exposing voice choice in your UI, group by language first, then let users pick a specific voice within their language — a flat alphabetical list of fifty voice names is not a feature, it's a wall of text.
Rate and pitch: use the constants, not raw guesses
let utterance = AVSpeechUtterance(string: text)
utterance.voice = defaultVoice()
utterance.rate = AVSpeechUtteranceDefaultSpeechRate // not a raw Float you guessed at
utterance.pitchMultiplier = 1.0 // range is 0.5 to 2.0
utterance.preUtteranceDelay = 0.0
AVSpeechUtteranceDefaultSpeechRate exists because Apple already tuned it against real listening comprehension. If you're building a speed slider, treat that constant as your slider's midpoint and let users move a bounded amount in either direction — don't let the raw rate value go anywhere near its technical minimum or maximum, both of which are close to unintelligible.
Actually knowing what the synthesizer is doing
This is the part almost every "hello world" skips, and it's the part your UI actually needs:
final class SpeechController: NSObject, ObservableObject {
@Published var isSpeaking = false
private let synthesizer = AVSpeechSynthesizer()
override init() {
super.init()
synthesizer.delegate = self
}
func speak(_ text: String) {
let utterance = AVSpeechUtterance(string: text)
utterance.voice = defaultVoice()
synthesizer.speak(utterance)
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
}
}
extension SpeechController: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
DispatchQueue.main.async { self.isSpeaking = true }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
DispatchQueue.main.async { self.isSpeaking = false }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
DispatchQueue.main.async { self.isSpeaking = false }
}
}
Without wiring the delegate, your play button has no way of knowing whether it should currently say "Play" or "Stop" — a surprisingly common bug in TTS features that were only ever tested by their own developer, who always remembers the internal state because they just clicked the button themselves.
Things that bite you in production
stopSpeaking(at:) takes a boundary parameter, and .immediate versus .word matters. Stopping .immediate cuts off mid-syllable, which sounds broken. Stopping .word finishes the current word first and sounds like a deliberate pause. Default to .word for anything user-facing.
Audio session category determines whether speech ducks other audio or fights it. If you don't explicitly configure AVAudioSession, you'll get inconsistent behavior around background music, other apps, and silent mode depending on the device state. Set your category deliberately — .playback with the .duckOthers option is the right default for most utility apps that speak occasionally rather than continuously.
Long text needs chunking, not one giant utterance. A single AVSpeechUtterance built from several paragraphs works, but a user has no way to skip forward a sentence, and a mid-read interruption loses your place entirely. Splitting text into sentence-level utterances queued sequentially costs a little extra bookkeeping and buys you skip-forward, skip-back, and resume-from-here almost for free.
Test with VoiceOver actually running. If your app has any other spoken content or accessibility announcements, a badly scoped TTS feature will compete with VoiceOver for the same audio output and produce genuinely confusing overlapping speech. This is easy to miss if you never test with VoiceOver on, and it's exactly the audience most likely to depend on your TTS feature actually working correctly.
None of this is exotic. It's the difference between a feature that technically speaks text out loud and one that feels like a considered part of the app, and the whole gap closes with about an hour of deliberate work most tutorials skip.
Top comments (0)