markbattistella/audiomanager
AudioManager is a Swift package that provides a modular and easy-to-use interface for implementing audio feedback in your applications. It integrates seamlessly with SwiftUI, enabling you to enhance user experience through customisable audible feedback.
Features
- SwiftUI Extensions: Add audio feedback to views declaratively via
.audioFeedback(...). - System Sounds: Play built-in iOS system sounds via
SystemSound. - Custom Audio: Bring your own audio files using
CustomSoundRepresentable. - Playback Behaviour: Choose whether audio respects the ringer/silent switch or the device volume level.
- User Preferences: Enable or disable audio feedback globally through a simple
UserDefaultskey.
Installation
Add AudioManager to your Swift project using Swift Package Manager.
dependencies: [
.package(url: "https://github.com/markbattistella/AudioManager", from: "26.0.0")
]Requirements
- Swift 6.0+
- iOS 14+
- macOS 11+
- Mac Catalyst 14+
- tvOS 14+
- visionOS 1+
Usage
There are three ways to trigger audio feedback:
- Static Action: Triggers on every state change — ideal for consistent feedback.
- Static Action with Condition: Adds a condition closure that gates whether feedback fires.
- Dynamic Action: Derives the sound itself from the old and new trigger values.
[!NOTE]
.system(SystemSound)plays files from/System/Library/Audio/UISounds/, which are only present on iOS, tvOS, visionOS, and macCatalyst. On macOS, use.custom(CustomSoundRepresentable)with your own bundled audio files.
Static Action
@State private var isSuccess: Bool = false
Button("Toggle") { isSuccess.toggle() }
.audioFeedback(.system(.ui(.tock)), trigger: isSuccess)Static Action with Condition
Old and new values
enum Phase { case inactive, active, completed }
@State private var phase: Phase = .inactive
Button("Advance") { ... }
.audioFeedback(.system(.ui(.tink)), trigger: phase) { oldValue, newValue in
oldValue != .completed && newValue == .completed
}New value only
Button("Advance") { ... }
.audioFeedback(.system(.ui(.tink)), trigger: phase) { newValue in
newValue == .completed
}No parameters
Button("Toggle") { phase.toggle() }
.audioFeedback(.system(.ui(.tink)), trigger: phase) {
// fires on every change
}Dynamic Action
Old and new values
enum LoadingState { case ready, success, failure }
@State private var loadingState: LoadingState = .ready
Button("Advance") { ... }
.audioFeedback(trigger: loadingState) { oldValue, newValue in
switch (oldValue, newValue) {
case (.failure, .ready):
return .system(.modern(.cameraShutterBurstBegin))
case (.ready, .success):
return .system(.nano(.screenCapture))
case (.success, .failure):
return .system(.new(.update))
default:
return nil
}
}New value only
Button("Advance") { ... }
.audioFeedback(trigger: loadingState) { newValue in
switch newValue {
case .success:
return .system(.modern(.cameraShutterBurstBegin))
case .failure:
return .system(.nano(.screenCapture))
default:
return nil
}
}No parameters
Button("Advance") { ... }
.audioFeedback(trigger: loadingState) {
return .system(.ui(.tock))
}Playback Behaviour
By default, audio respects the hardware ringer/silent switch — the same behaviour as system UI sounds. You can change this globally so audio instead responds to the device volume level, bypassing the ringer switch entirely.
| Behaviour | Plays when… | Engine | | --- | --- | --- | | .respectRinger (default) | Ringer switch is on | AudioServicesPlaySystemSound | | .respectVolume | Device volume is > 0 | AVAudioPlayer (.playback session) |
[!NOTE]
AudioPlaybackBehavioris an iOS/tvOS/visionOS/macCatalyst concept. On macOS there is no ringer switch, so both behaviours fall through toAudioServicesPlaySystemSoundand behave identically.
Set the behaviour via UserDefaults.audio:
// Ringer switch controls playback (default)
UserDefaults.audio.set(
AudioPlaybackBehavior.respectRinger.rawValue,
for: AudioUserDefaultsKey.audioPlaybackBehavior
)
// Volume slider controls playback — ringer switch is ignored
UserDefaults.audio.set(
AudioPlaybackBehavior.respectVolume.rawValue,
for: AudioUserDefaultsKey.audioPlaybackBehavior
)The .respectVolume session is configured with .mixWithOthers, so it will not interrupt music or other audio playing in the background. Because AVAudioSession is process-wide, this sets the shared session to .playback and leaves it active. If your app owns a custom audio session policy, reapply your preferred category after playback or keep this package in .respectRinger.
Read the current behaviour at any time:
let current = AudioFeedbackPerformer<Never>.playbackBehaviorConfiguring Audio Settings
Enabling audio effects
Audio effects are disabled by default. Enable them on app launch:
@main
struct MyApp: App {
init() {
UserDefaults.audio.set(true, for: AudioUserDefaultsKey.audioEffectsEnabled)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Or toggle it in response to a user action, such as a settings screen:
Toggle("Sound Effects", isOn: $soundEnabled)
.onChange(of: soundEnabled) { _, enabled in
UserDefaults.audio.set(enabled, for: AudioUserDefaultsKey.audioEffectsEnabled)
}[!IMPORTANT] The package only reads from the internal
.audioUserDefaultssuite. Settings written to.standardor any other suite are ignored.
Available keys
| Key | Type | Default | Purpose | | --- | --- | --- | --- | | audioEffectsEnabled | Bool | false | Master on/off for all audio feedback | | audioPlaybackBehavior | Int (raw value) | 0 (.respectRinger) | Controls how volume/ringer affects playback |
Custom Audio
If the built-in system sounds are not sufficient, bring your own audio files using the .custom(CustomSoundRepresentable) playback case.
Supported formats: .aif, .aiff, .caf, .mp3, .wav Maximum duration: 30 seconds
1. Define a conforming type
enum MySound: CustomSoundRepresentable {
case success
case failure
var soundFile: SoundFile {
switch self {
case .success: SoundFile(name: "success", extension: .caf)
case .failure: SoundFile(name: "failure", extension: .caf)
}
}
}2. Add the files to your app target
Ensure the audio files are included in your app's main bundle.
3. Use in a view
@State private var didSave = false
Button("Save") { didSave.toggle() }
.audioFeedback(.custom(MySound.success), trigger: didSave)Example App
The repository includes an iOS example app that demonstrates both playback behaviours side by side. Open AudioManager.xcworkspace to see the package sources and the example project together in one workspace.
AudioManager/
├── AudioManager.xcworkspace
├── Sources/AudioManager/
└── Example/
└── AudioManagerExample/The example app has two sections:
- Ringer Mode — buttons that use
.respectRinger. Flip the physical silent switch off and the sounds stop, regardless of the volume slider. - Volume Mode — buttons that use
.respectVolume. The silent switch is ignored; sounds play when the volume slider is above zero, and the buttons are disabled when it reaches zero.
[!NOTE] Run the example on a real device. The simulator does not have a ringer switch, and volume KVO notifications do not fire reliably in the simulator.
What is only in the example
AudioSessionMonitor is a KVO wrapper around AVAudioSession.outputVolume used to keep the volume progress bar in the example UI reactive. It is not part of the library and is not needed in a real app. The library reads outputVolume on demand at the moment perform() is called — no persistent observer is required.
Contributing
Contributions are always welcome. Feel free to submit a pull request or open an issue for any suggestions or improvements.
License
AudioManager is licensed under the MIT License. See the LICENSE file for more details.
Package Metadata
Repository: markbattistella/audiomanager
Default branch: main
README: README.md