Adding synthesized speech to calls
At a glance
| Item |
Summary |
| Purpose |
Adds synthesized app audio to phone and FaceTime calls after checking system and app permission. |
| App architecture |
A SwiftUI view owns a main-actor call-audio coordinator and a speech actor. |
| Main patterns |
View-owned services, observable state, actor isolation, async notification consumption. |
| Project style |
Small single-target app with one file per major type. |
Project structure
AddSynthesizedSpeechToCalls/
├── AddSynthesizedSpeechToCallsApp.swift
├── ContentView.swift
├── CallAudio.swift
└── SpeechSynthesizer.swift
Structure observations
- UI intent and alert state stay in
ContentView; framework configuration and observation stay in CallAudio.
- Speech synthesis is a separate actor rather than another responsibility of the view or call coordinator.
Overall architecture
flowchart LR
User["User"] --> View["ContentView"]
View --> Call["CallAudio"]
View --> Speech["SpeechSynthesizer actor"]
Call --> App["AVAudioApplication permission"]
Call --> Session["AVAudioSession injection mode"]
Speech --> Synth["AVSpeechSynthesizer"]
Reference code
AddSynthesizedSpeechToCalls/ContentView.swift:33 — the toggle converts a UI intent into an asynchronous coordinator call.
private var toggleBinding: Binding<Bool> {
Binding { addAudioToCalls } set: { newValue in
Task {
switch await callAudio.setAppAudioEnabled(newValue) {
case .success(let state): addAudioToCalls = state
case .failure: addAudioToCalls = false
}
}
}
}
Interpretation
The view presents intent and results, while the two service types contain AVFAudio operations. CallAudio is the authoritative source for call availability and microphone-injection state; SpeechSynthesizer serializes access to its synthesizer through actor isolation.
Ownership and state
classDiagram
ContentView *-- CallAudio : State creates
ContentView *-- SpeechSynthesizer : State creates
SpeechSynthesizer *-- AVSpeechSynthesizer : owns
CallAudio --> AVAudioSession : shared service
CallAudio --> AVAudioApplication : shared service
Ownership evidence
AddSynthesizedSpeechToCalls/ContentView.swift:13 — the root content view creates both long-lived collaborators.
@State private var callAudio = CallAudio()
@State private var speechSynth = SpeechSynthesizer()
| Owner |
Object or state |
Relationship |
Mutation authority |
ContentView |
UI text, toggle, alerts |
Owns SwiftUI state |
ContentView |
ContentView |
CallAudio, SpeechSynthesizer |
Creates and retains through @State |
Each service mutates its own internals |
CallAudio |
Injection and call state |
Coordinates shared system services |
CallAudio |
SpeechSynthesizer |
AVSpeechSynthesizer |
Creates and actor-isolates |
SpeechSynthesizer |
Class and protocol design
| Type |
Responsibility |
Depends on |
ContentView |
Collect text, present status, translate controls into intents |
CallAudio, SpeechSynthesizer |
CallAudio |
Check permission, set injection mode, observe call/media-service changes |
AVAudioApplication, AVAudioSession |
SpeechSynthesizer |
Select a voice and speak utterances serially |
AVSpeechSynthesizer |
AppAudioError |
Express user-actionable enablement failures |
Swift Error |
No app-defined protocol is needed because there is one concrete implementation for each narrow capability.
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
CallAudio.isCallActive |
private(set) |
Views can read it; only CallAudio can update it. |
Protect notification-derived truth. |
CallAudio.isAppAudioEnabled |
private |
Hidden outside the coordinator. |
Keep system mode and cached state synchronized. |
setMicrophoneInjectionMode |
private |
Only permission-checked paths can change the mode. |
Preserve the enablement invariant. |
SpeechSynthesizer.speechSynth |
private |
The underlying mutable synthesizer cannot escape its actor. |
Maintain actor isolation. |
| App types |
implicit internal |
Visible only within the app module. |
No reusable framework API is being published. |
Reference code
AddSynthesizedSpeechToCalls/CallAudio.swift:14 — readable state exposes no external setter.
private(set) var isCallActive = false
private var isAppAudioEnabled = false
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Toggle/alert presentation |
ContentView |
View-specific state and rendering. |
| Permission and injection policy |
CallAudio |
One boundary around coupled system APIs. |
| Call and reset observation |
CallAudio |
Events mutate coordinator-owned state. |
| Utterance construction and playback |
SpeechSynthesizer |
Keeps mutable synthesizer work off the view. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Observable coordinator |
AddSynthesizedSpeechToCalls/CallAudio.swift:10 |
Main-actor state can drive SwiftUI without exposing system details. |
| Actor-isolated service |
AddSynthesizedSpeechToCalls/SpeechSynthesizer.swift:10 |
Serializes a mutable speech engine behind an async boundary. |
| Async event stream |
AddSynthesizedSpeechToCalls/CallAudio.swift:82 |
Consumes notification sequences with structured asynchronous loops. |
| Result-based boundary |
AddSynthesizedSpeechToCalls/CallAudio.swift:32 |
Returns domain errors instead of making the view infer permission outcomes. |
Naming conventions
- Types use responsibility nouns:
CallAudio, SpeechSynthesizer, and AppAudioError.
- Boolean names use
is/state phrasing: isCallActive, isAppAudioEnabled, addAudioToCalls.
- Commands use verbs:
setAppAudioEnabled, synthesizeSpeech, observeCallState.
- Files match their principal type; UI and service boundaries are immediately visible in the tree.
Architecture takeaways
- Keep permission and system-mode changes in one coordinator with a domain result type.
- Expose notification-derived state as read-only and retain mutation authority in the observer.
- Use a separate actor when a mutable framework object represents an independent capability.
- A small sample benefits from explicit boundaries without requiring a protocol for every class.
Source map
| Source file |
Relevant symbols |
AddSynthesizedSpeechToCalls/ContentView.swift |
ContentView, toggleBinding, textToSpeech |
AddSynthesizedSpeechToCalls/CallAudio.swift |
CallAudio, setAppAudioEnabled, observers |
AddSynthesizedSpeechToCalls/SpeechSynthesizer.swift |
SpeechSynthesizer |
AddSynthesizedSpeechToCalls/AddSynthesizedSpeechToCallsApp.swift |
App entry |