Creating a custom speech synthesizer
At a glance
| Item |
Summary |
| Purpose |
Publishes custom speech voices from an extension and renders requested speech from bundled audio. |
| App architecture |
A SwiftUI host edits shared voice configuration; an Audio Unit extension exposes and renders those voices for the system. |
| Main patterns |
Host/extension split, factory, app-group handoff, framework subclass hooks, real-time render callback. |
| Project style |
Two targets with separate host and extension folders; the app-group suite is their only data bridge. |
Project structure
CustomSpeechSynthesizerExample/
├── CustomSpeechSynthesizerExample/
│ ├── CustomSpeechSynthesizerExampleApp.swift
│ └── ContentView.swift
└── CustomSpeechSynthesizerExampleExtension/
├── AudioUnitFactory.swift
├── CustomSpeechSynthesizerExampleAudioUnit.swift
└── Info.plist
Structure observations
- Target folders make the process boundary explicit: the host never owns the provider audio unit.
- Both targets independently open the same app-group
UserDefaults suite rather than sharing in-memory objects.
Overall architecture
flowchart LR
User["Host-app user"] --> View["ContentView"]
View --> Defaults["App-group UserDefaults"]
View --> Notify["updateSpeechVoices()"]
System["Speech system"] --> Factory["AudioUnitFactory"]
Factory --> Unit["CustomSpeechSynthesizerExampleAudioUnit"]
Unit --> Defaults
System --> Request["AVSpeechSynthesisProviderRequest"]
Request --> Unit
Unit --> Audio["Bundled AIFF → PCM render block"]
Reference code
CustomSpeechSynthesizerExample/ContentView.swift:55 — the host persists configuration and explicitly notifies the speech system.
private func saveVoicesToGroupDefaults() {
groupDefaults?.set(voices, forKey: "voices")
AVSpeechSynthesisProviderVoice.updateSpeechVoices()
}
Interpretation
This is a multi-target architecture. The host is a configuration UI; the extension is the runtime provider. Durable app-group storage is the cross-process contract, and system callbacks drive extension creation and rendering.
Ownership and state
classDiagram
ContentView *-- HostVoiceState : State owns
ContentView o-- UserDefaults : shared suite
AudioUnitFactory *-- CustomSpeechSynthesizerExampleAudioUnit : creates
CustomSpeechSynthesizerExampleAudioUnit o-- UserDefaults : shared suite
CustomSpeechSynthesizerExampleAudioUnit *-- AUAudioUnitBus : owns
CustomSpeechSynthesizerExampleAudioUnit *-- AVAudioPCMBuffer : request state
Ownership evidence
CustomSpeechSynthesizerExampleExtension/AudioUnitFactory.swift:10 — the extension factory constructs and retains the provider instance.
public class AudioUnitFactory: NSObject, AUAudioUnitFactory {
var audioUnit: AUAudioUnit?
public func createAudioUnit(with componentDescription: AudioComponentDescription) throws -> AUAudioUnit {
audioUnit = try CustomSpeechSynthesizerExampleAudioUnit(
componentDescription: componentDescription, options: [])
return audioUnit!
}
}
| Owner |
Object or state |
Relationship |
Mutation authority |
ContentView |
Voice names and input text |
Owns SwiftUI state |
Host view |
| App-group defaults |
Serialized voice list |
Shared external store |
Host writes; extension reads |
AudioUnitFactory |
Current provider audio unit |
Creates and retains |
System factory callback |
| Provider audio unit |
Request, bus, buffer, format, frame position |
Creates/retains render state |
Provider request/render methods |
Class and protocol design
| Type |
Responsibility |
Depends on |
ContentView |
Edit and persist available voice names |
SwiftUI, app-group defaults |
AudioUnitFactory |
Fulfill the system’s AUAudioUnitFactory creation contract |
CoreAudioKit |
CustomSpeechSynthesizerExampleAudioUnit |
Advertise voices and render requested speech |
AVSpeechSynthesisProviderAudioUnit |
AVSpeechSynthesisProviderVoice |
System-facing voice description |
Names from shared defaults |
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
| Factory class and factory methods |
public |
Visible to extension infrastructure outside the Swift module. |
Required system entry boundary. |
| Provider class and overridden hooks |
public |
System can query voices/busses and invoke request/render hooks. |
Framework subclass contract. |
| Request, bus, buffer, frame position, format |
private |
No other extension type can corrupt render state. |
Preserve request/render invariants. |
saveVoicesToGroupDefaults |
private |
Only the host view’s add/delete actions persist and notify. |
Localize side effects. |
| Host view state |
implicit internal |
Accessible in the host target only. |
No public app API. |
Reference code
CustomSpeechSynthesizerExampleExtension/CustomSpeechSynthesizerExampleAudioUnit.swift:10 — public system hooks wrap private mutable render state.
public class CustomSpeechSynthesizerExampleAudioUnit: AVSpeechSynthesisProviderAudioUnit {
private var request: AVSpeechSynthesisProviderRequest?
private var currentBuffer: AVAudioPCMBuffer?
private var framePosition: AVAudioFramePosition = 0
private var format: AVAudioFormat
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Voice editing and persistence |
Host ContentView |
Configuration UI concern. |
| Provider construction |
AudioUnitFactory |
Extension entry contract. |
| Voice advertisement |
Provider speechVoices override |
System queries the audio unit. |
| SSML-to-sample selection |
getAudioBufferForSSML |
Sample synthesis policy belongs to provider. |
| Frame copying/completion |
internalRenderBlock |
Audio-unit render boundary. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Host app + extension |
CustomSpeechSynthesizerExample/ContentView.swift:15 and CustomSpeechSynthesizerExampleExtension/CustomSpeechSynthesizerExampleAudioUnit.swift:14 |
Separates configuration UI from system-hosted rendering. |
| Factory |
CustomSpeechSynthesizerExampleExtension/AudioUnitFactory.swift:19 |
Lets Core Audio instantiate the correct provider type. |
| Shared-store handoff |
CustomSpeechSynthesizerExample/ContentView.swift:58 |
Communicates across targets without direct object ownership. |
| Template method/subclass hooks |
CustomSpeechSynthesizerExampleExtension/CustomSpeechSynthesizerExampleAudioUnit.swift:53 |
Implements framework-defined customization points. |
| Render callback |
CustomSpeechSynthesizerExampleExtension/CustomSpeechSynthesizerExampleAudioUnit.swift:74 |
Fills system-supplied buffers on demand. |
Naming conventions
- Target-qualified type names (
CustomSpeechSynthesizerExampleAudioUnit) make the provider role explicit.
- Factory and request methods mirror framework terminology:
createAudioUnit, synthesizeSpeechRequest, cancelSpeechRequest.
- Private storage uses implementation-oriented names such as
_outputBusses and currentBuffer.
- Host and extension directories match Xcode target boundaries.
Architecture takeaways
- Treat the app group as a data contract, not shared ownership between processes.
- Keep system-facing extension entry points public and render state private.
- Use a factory as the only construction route for framework-hosted components.
- Put host configuration and provider execution in separate targets and document both halves.
Source map
| Source file |
Relevant symbols |
CustomSpeechSynthesizerExample/ContentView.swift |
Voice state, shared-default persistence, system notification |
CustomSpeechSynthesizerExample/CustomSpeechSynthesizerExampleApp.swift |
Host app entry |
CustomSpeechSynthesizerExampleExtension/AudioUnitFactory.swift |
Extension factory |
CustomSpeechSynthesizerExampleExtension/CustomSpeechSynthesizerExampleAudioUnit.swift |
Voice provider and render block |