Tutorial: Audio SwiftUI
Cover Page
DUE Thu, 10/16, 2 pm
This tutorial can largely be completed on the iOS simulator.
Preparing your GitHub repo
- On your laptop, navigate to
YOUR*TUTORIALS/ - Unzip your
chatter.zipfile. Double check that you still have a copy of the zip file for future reference! - Rename your newly unzipped
chatterfolder**audio** - Check whether there’s a
DerivedDatafolder in yourswiftUIChatterfolder; if so, delete it:laptop$ cd YOUR*TUTORIALS/audio/swiftUIChatter laptop$ ls -d DerivedData # if DerivedData exists: laptop$ rm -rf DerivedData - Push your local
YOUR*TUTORIALS/repo to GitHub and make sure there’re no git issues:<summary>git push</summary>- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on your assignment GitHub repo
- Add Summary to your changes and click
Commit to main - If you have pushed other changes to your Git repo, click
Pull Originto synch up the clone on your laptop - Finally click on
Push Originto push changes to GitHub
- Open GitHub Desktop and click on
Go to the GitHub website to confirm that your folders follow this structure outline:
reactive
|-- audio
|-- swiftUIChatter
|-- swiftUIChatter.xcodeproj
|-- swiftUIChatter
|-- chatterd
|-- chatterd.crt
# and other files or folders
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.
Working with audio
Next up: how to record, playback, pause, fast forward, rewind, stop, and upload audio. We will use the AVFoundation library: AVAudioRecorder to record from the device’s microphone and save to local file, and AVAudioPlayer to play back audio on the device’s speakers. We’ll put all interactions with the AVFoundation, to control the audio player, in AudioPlayer.
The UI to access the audio player we put in AudioView.
Requesting permission
To record audio, we need user’s permission to access the device’s microphone. Add justification to use the microphone to your Info list. Click on your project name (first item in your left/navigator pane), then click on the project in the TARGETS section, and then the Info tab. In the Custom iOS Target Properties section, right click (or ctl-click) on any row in the table and choose Add Row (screenshot). In the drop down menu, select Privacy - Microphone Usage Description and, in the Value field to the right, enter the reason you want to access the mic, for example, “to record audio chatt”. What you enter into the value field will be displayed to the user when seeking their permission (screenshot).
When you try to access the mic, iOS will automatically check for access permission and, if it is your app’s first attempt to access the mic, iOS will automatically prompt the user for permission.
If you accidentally denied permission when your app requested it, go to
Settings > Privacy & Security > Microphone, locate your app and slide the corresponding toggle to allow access.
AudioPlayer
Controlling the audio player itself is rather straight forward. The more complicated
part is how to handle the audio file. When the user clicks on a posted audio file,
we play back it back and let user change the playback point back and forth. We call
this the playbackMode. When the user starts recording, we enter a recordingMode
and creata a recorded audio file. After recording, user may want to play the
recording back before posting. They may decide to record over the file. Or to delete
it all together and not post. After recording, they may want to play back a
posted audio before deciding what to do with the recording. The recordingMode spans
all of these potential activities, including the “nested” play back. We exit a recordingMode
only the user has made a final decision on what to do with the recorded audio file.
Create a new Swift file, call it AudioPlayer. Create an observable AudioPlayer
class and put the following properties in it: its two modes, audio files, and
control of the audio player, along with the initialization of the audio player:
import Foundation
import AVFoundation
import Observation
@Observable
final class AudioPlayer: NSObject, AVAudioRecorderDelegate, AVAudioPlayerDelegate {
// playback and recording modes:
var recordingMode = false
var playbackMode = false
// audio files:
var playback: Data? = nil
var recorded: Data? = nil
private let audioFilePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("chatteraudio.m4a")
// audio player:
private let audioSession = AVAudioSession.sharedInstance()
private var audioRecorder: AVAudioRecorder! = nil
private var audioPlayer: AVAudioPlayer! = nil
// audio player states:
var isRecording = false
var isPlaying = false
override init() {
super.init()
do {
try audioSession.setCategory(.playAndRecord, mode: .default)
try audioSession.setActive(true)
} catch {
print("AudioPlayer: failed to setup AVAudioSession")
}
}
// audio player controls
}
AudioPlayer dates back to iOS’s Objective-C era and must be declared an NSObject
subtype so that it can conform to the AVAudioRecorderDelegate and AVAudioPlayerDelegate
protocols by which the AVFoundation subsystem delivers various playback events via
callback functions. The property audioFilePath must be initialized to point to a
temporary audio file. AVAudioRecorder requires the audio data to be stored in a file,
as opposed to memory.
We tag AudioPlayer @Observable so that changes to its properties can be observed
and reflected by AudioView.
From “standby”, the audio player can be put in recordMode or playbackMode. Add
the following AudioPlayer methods where the // audio player controls comment is:
func setupRecorder() {
doneTapped()
recordingMode = true
playbackMode = false
guard audioRecorder != nil else {
let settings = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 12000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
audioRecorder = try? AVAudioRecorder(url: audioFilePath, settings: settings)
guard let _ = audioRecorder else {
print("setupRecorder: failed")
return
}
audioRecorder.delegate = self
return
}
}
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
print("Error encoding audio: \(error!.localizedDescription)")
audioRecorder.stop()
isRecording = false
}
func setupPlayer(_ audioStr: String) {
if let audio = Data(base64Encoded: audioStr, options: .ignoreUnknownCharacters) {
playbackMode = true
playback = audio
preparePlayer(audio)
playTapped()
}
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
print("Error decoding audio \(error?.localizedDescription ?? "on playback")")
// don't dismiss, in case user wants to record
}
// preparing the player
We provide a callback handler for encoding error, as required by conformance to the AVAudioRecorderDelegate protocol. Similarly, we provide a callback handler for
decoding error to conform to the AVAudioPlayerDelegate protocol. In the case of
encoding error during recording, we stop the recording.
When the player is set to playbackMode, we expect to be passed along a base64-encoded
audio string to be played back. This would normally be an audio clip associated with a
posted chatt. We store the decoded string in the audio property and prepare the
AVAudioPlayer for playback.
AVAudioPlayer
Add the following preparePlayer() method to the AudioPlayer class at the
// preparing the player comment:
private func preparePlayer(_ audio: Data) {
audioPlayer = try? AVAudioPlayer(data: audio)
guard let audioPlayer else {
print("preparePlayer: failed")
return
}
audioPlayer.volume = 10.0
audioPlayer.delegate = self
audioPlayer.prepareToPlay()
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stopTapped()
}
// playback point controls
Conforming to the AVAudioPlayerDelegate protocol, we provided AVAudioPlayer a
callback handler audioPlayerDidFinishPlaying().
With the AVAudioPlayer set up, we now fill in the functions to control the playback
point. They go where the // playback point controls comment is, one after another,
starting with playTapped(), which toggles the audioPlayer between pause() and
play():
func playTapped() {
guard let audioPlayer else {
print("playTapped: no audioPlayer!")
return
}
if audioPlayer.isPlaying {
audioPlayer.pause()
isPlaying = false
} else {
audioPlayer.play()
isPlaying = true
}
}
The fast-forward and rewind playback controls simply move the play head forward or
backward by 10 seconds, by assigning a new value to audioPlayer.currentTime, respectively:
func ffwdTapped() {
audioPlayer.currentTime = min(audioPlayer.duration, audioPlayer.currentTime + 10.0) // seconds
}
func rwndTapped() {
audioPlayer.currentTime = max(0, audioPlayer.currentTime - 10.0) // seconds
}
When the stop button is tapped, if we’re in simple playbackMode mode, we simply reset the
play head to the beginning of the audio clip, by assigning resetting currentTime to 0.
If we’re playing back while “nested” in a recordingMode, however, we reset the
playback audio file to the recorded audio file so that the user can resume working
with the recorded audio file, whether to post, re-record, or delete it.
func stopTapped() {
isPlaying = false
audioPlayer.stop()
if playbackMode && recordingMode, let recorded {
playback = recorded
preparePlayer(recorded)
} else {
audioPlayer.currentTime = 0
}
}
AVAudioRecorder
Once the recorder is set up, by a call to setupRecorder() defined earlier,
recording is initiated by the user tapping the record button, which calls
recTapped():
func recTapped() {
if isRecording {
audioRecorder.stop()
if let audio = try? Data(contentsOf: audioFilePath) {
recorded = audio
playback = audio
preparePlayer(audio)
}
isRecording = false
} else {
playback = nil
recorded = nil
audioRecorder.record()
isRecording = true
}
}
Similar to playTapped(), recTapped() toggles recording. When recording is
stopped, we point playback to the resulting recorded file and prepare
AVAudioPlayer for play back in case the user wants to play back the recorded
audio before deciding what to do with it.
Once the user is satisfied with the recording, doneTapped() stops any ongoing
recording and playback. If the audio player is in recordingMode and something
has been recorded, even if we’re leaving the audio player, we’re not really done
since the user hasn’t yet decided what to do with the recorded file. So we
set playback file to point to the recorded file and prepare the player to
play it back should the user wishes to do so. Otherwise, we set the audio player
back to “standby” mode.
func doneTapped() {
if isRecording {
audioRecorder.stop()
isRecording = false
}
if isPlaying {
audioPlayer.stop()
isPlaying = false
}
if recordingMode, let recorded {
// restore recorded audio
playback = recorded
preparePlayer(recorded)
} else {
playback = nil
recordingMode = false
playbackMode = false
}
}
The user can dispose of the recorded audio file in two ways: post it or delete it.
Once the user made their decision, we call endRecording() to finally exit the
recordingMode and delete the recording:
func endRecording() {
isRecording = false
recordingMode = false
playback = nil
recorded = nil
audioRecorder?.deleteRecording() // clean up
}
Hoisting the AudioPlayer
We will have one instance of AudioPlayer for the whole app. For this
instance of AudioPlayer to be accessible to all Views in the app, we
instantiate it in swiftUIChatterApp and make it available as an
environment object at the very root of the app’s View hierarchy.
Add the environment(_:) modifier to your NavigationStack() in
swiftUIChatterApp:
NavigationStack {
ContentView()
// . . .
}
.environment(AudioPlayer())
Be sure to retain your existing .environment(viewModel) while
hoisting the instance of AudioPlayer into the environment.
While we’re in swiftUIChatterApp.swift, add the following property
to your ChatViewModel:
var showPlayer = false
We’ll use it to control the showing of AudioView later.
With that, we are done with AudioPlayer!
Now we declare the UI to go along with the audio player.
AudioView
The UI for our audio player consists of buttons one would expect of an audio player: record, play, stop, rewind, and fast forward. In addition, we also have a “done” button, for when the user is done and wish to exit the audio player, and a “trash” button, to delete recorded audio without posting it.
Create a new Swift file, call it AudioView and put the following AudioView struct in it:
import SwiftUI
import Observation
struct AudioView: View {
@Environment(ChattViewModel.self) private var vm
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
HStack {
RecButton()
Spacer()
// just for "decoration"
Image(systemName: "waveform")
.foregroundColor(.blue.opacity(0.5))
.scaleEffect(0.8)
Spacer()
StopButton()
Spacer()
RwndButton()
Spacer()
PlayButton()
Spacer()
FfwdButton()
Spacer()
// just for "decoration"
Image(systemName: "waveform")
.foregroundColor(.blue.opacity(0.5))
.scaleEffect(0.8)
Spacer()
if audioPlayer.recordingMode {
TrashButton()
} else {
DoneButton()
}
}
.frame(height:24)
.font(.title)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
)
.padding(.horizontal, 16)
.padding(.bottom, 8)
.onDisappear {
audioPlayer.doneTapped()
}
}
}
The modifier .onDisappear(perform:) allows us to call a function with side-effect
when a View disappears. In this case, when user exits AudioView() we call doneTapped()
to stop all playback and recording.
The play back buttons are pretty straightforward. They are enabled only when
the audio player is playing. Put each outside your AudioView struct:
struct StopButton: View {
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.stopTapped()
} label: {
Image(systemName: "stop.fill")
.scaleEffect(0.7)
}
.disabled(!audioPlayer.isPlaying)
}
}
struct RwndButton: View {
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.rwndTapped()
} label: {
Image(systemName: "gobackward.10")
.scaleEffect(0.8)
}
.disabled(!audioPlayer.isPlaying)
}
}
struct FfwdButton: View {
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.ffwdTapped()
} label: {
Image(systemName: "goforward.10")
.scaleEffect(0.8)
}
.disabled(!audioPlayer.isPlaying)
}
}
struct PlayButton: View {
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.playTapped()
} label: {
Image(systemName: audioPlayer.isPlaying
? "pause.fill" : "play.fill")
.scaleEffect(0.8)
}
.disabled(audioPlayer.playback == nil)
}
}
The play button toggles between play and pause, showing the
corresponding icon as appropriate. The record button similarly
toggles between recording or not recording. Further, it is shown
and enabled only when the audio player is in recordingMode and
is not playing. If we don’t show a button when it is disabled,
the space it would have occupied will be filled by another view
instead of being left blank. To leave a blank space of the right
dimension when a View is disabled, we render it with opacity 0.
struct RecButton: View {
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.recTapped()
} label: {
Image(systemName: audioPlayer.isRecording ? "stop.circle.fill" : "record.circle.fill")
.scaleEffect(0.8)
.foregroundColor(Color(audioPlayer.isRecording ? .systemRed : .systemBlue))
}
.disabled(!audioPlayer.recordingMode || audioPlayer.isPlaying)
.opacity(!audioPlayer.recordingMode || audioPlayer.isPlaying ? 0 : 1)
}
}
In addition to calling doneTapped() the done button also dismisses
the AudioView:
struct DoneButton: View {
@Environment(ChattViewModel.self) private var vm
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.doneTapped()
vm.showPlayer = false
} label: {
Image(systemName: audioPlayer.recordingMode ?
"square.and.arrow.up" : "xmark.square")
.scaleEffect(0.8)
.foregroundColor(Color(.systemBlue))
}
.disabled(audioPlayer.isRecording)
}
}
The trash button not only does what the done button does, it also
calls endRecording() to reset the audio player’s recordingMode
and delete any recorded audio. The trash button is enabled only
when there is recorded audio and the audio player is not playing,
including “nested” play back in the middle of recording. It is not
shown at all unless the audio player is in recordingMode:
struct TrashButton: View {
@Environment(ChattViewModel.self) private var vm
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
audioPlayer.doneTapped()
audioPlayer.endRecording()
vm.showPlayer = false
} label: {
Image(systemName: "trash.fill")
.scaleEffect(0.7)
.foregroundColor(Color(.systemRed))
}
.disabled(audioPlayer.isPlaying || audioPlayer.recorded == nil)
.opacity(!audioPlayer.recordingMode ? 0 :
audioPlayer.isPlaying || audioPlayer.recorded == nil ? 0.2 : 1)
}
}
Now we’re really done with the audio player and its Views!
The networking
Chatt
Add a new stored property audio to the Chatt struct to hold the audio
associated with each chatt:
struct Chatt: Identifiable {
var username: String?
var message: String?
var id: UUID?
var timestamp: String?
var audio: String?
// so that we don't need to compare every property for equality
static func ==(lhs: Chatt, rhs: Chatt) -> Bool {
lhs.id == rhs.id
}
}
ChattStore
We update postChatt(_:errMsg) by:
- add an
audiofield toChattwhen preparingjsonObj, and - point
apiUrlto thepostaudioAPI endpoint. The rest of the function remains the same as in thechattertutorial.
Next we update getChatts(errMsg:). First update the apiUrl to point to
the getaudio endpoint. Then add decoding the audio field to chatts.append():
chatts.append(Chatt(username: chattEntry[0],
message: chattEntry[1],
id: UUID(uuidString: chattEntry[2] ?? ""),
timestamp: chattEntry[3],
audio: chattEntry[4]))
The UI
Now we update the app’s UI.
Recording and posting audio
Let’s define an AudioButton to show the AudioView to control the AudioPlayer.
Put the following in your ContentView.swift file, outside the ContentView struct:
struct AudioButton: View {
@Environment(ChattViewModel.self) private var vm
@Environment(AudioPlayer.self) private var audioPlayer
var body: some View {
Button {
if !vm.showPlayer {
audioPlayer.setupRecorder()
vm.showPlayer.toggle()
} else if audioPlayer.playbackMode {
audioPlayer.setupRecorder()
} else if !audioPlayer.isRecording {
vm.showPlayer.toggle()
}
} label: {
Image(systemName: audioPlayer.recorded == nil ? "mic" : "mic.fill")
.foregroundColor(Color(audioPlayer.recorded == nil ? .systemBlue : .systemRed))
.scaleEffect(1.1)
.padding(11)
}
.background(Color.secondary.opacity(0.2))
.clipShape(Circle())
}
}
Add an icon for the AudioButton to the left of the TextField View in ContentView.
When AudioButton is tapped, we want to show the AudioView above the AudioButton,
actually spanning the whole width of the screen. Replace the HStack { } in your
ContentView, below ScrollViewReader, with the following. We also update its
.padding modifier to make the whole ensemble look a bit nicer:
Divider()
VStack(spacing: 0) {
if (vm.showPlayer) {
AudioView()
.transition(.opacity)
}
HStack (alignment: .center, spacing: 10) {
AudioButton()
// Chatt input and submit
TextField(vm.instruction, text: Bindable(vm).message)
.focused($messageInFocus) // to dismiss keyboard
.textFieldStyle(.roundedBorder)
.cornerRadius(20)
.shadow(radius: 2)
.background(Color(.clear))
.border(Color(.clear))
SubmitButton(scrollProxy: $scrollProxy)
}
.padding(.horizontal, 20)
}
.padding(.vertical, 4)
When the user taps anywhere on the screen, we want to close the AudioView. Add to
the .onTapGesture {} of your outermost VStack in ContentView, the one
before .navigationTitle the following line:
vm.showPlayer = false
Then in your SubmitButton, grab the AudioPlayer environment object by adding
this property to the struct:
@Environment(AudioPlayer.self) private var audioPlayer
then update the call to postChatt(_:errMsg:) with:
vm.showPlayer = false
await ChattStore.shared.postChatt(Chatt(username: vm.username, message: vm.message.isEmpty ? "Audio message" : vm.message, audio: audioPlayer.recorded?.base64EncodedString()), errMsg: Bindable(vm).errMsg)
audioPlayer.doneTapped()
audioPlayer.endRecording()
After the user successfully posted a chatt, we stop all play back and recording
and end the recording “session” and delete the recorded audio.
Finally, update the conditions by which the SubmitButton is enabled to also check
for availability of audio recording:
.disabled(isSending || (vm.message.isEmpty && audioPlayer.recorded == nil))
.background(Color(isSending || (vm.message.isEmpty && audioPlayer.recorded == nil) ? .secondarySystemBackground : .systemBlue))
Displaying audio message notification
On the chatt timeline, if a chatt has audio data, we display a waveform icon
in its bubble, next to the message. Due to the alternating alignment display of text
bubbles, depending on whether the user is the poster, we put the waveform icon on
the right or left of the message, to go with the text bubble alignment. In
ChattScrollView.swift, in the ChattView struct, add two properties to grab
the ChattViewModel and AudioPlayer from the SwiftUI environment:
@Environment(ChattViewModel.self) private var vm
@Environment(AudioPlayer.self) private var audioPlayer
and wrap the Text displaying the message in an HStack so that a waveform icon
is displayed either to its left (isSender) or right (!isSender). Note that we move
the modifiers for the text bubble to encompass the whole HStack:
HStack(alignment: .top, spacing: 10) {
if isSender, let playback = chatt.audio {
Image(systemName: "waveform")
.foregroundStyle(.white)
.font(.caption)
.scaleEffect(1.4)
.padding(EdgeInsets(top: 5, leading: 3, bottom: 3, trailing: 3))
// tap to play back audio message
}
Text(msg)
if !isSender, let playback = chatt.audio {
Image(systemName: "waveform")
.foregroundStyle(.blue)
.font(.caption)
.scaleEffect(1.4)
.padding(EdgeInsets(top: 5, leading: 3, bottom: 3, trailing: 3))
// tap to play back audio message
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(isSender ? .systemBlue : .systemBackground))
.foregroundColor(isSender ? .white : .primary)
.cornerRadius(20)
.shadow(radius: 2)
.frame(maxWidth: 300, alignment: isSender ? .trailing : .leading)
When user taps the waveform icon, we want to play back the audio message associated with
the chatt and show the audio control AudioView. To achive that, we add a tap gesture recognizer to both waveform icons. Replace
the two instances of // tap to play back audio message with:
.onTapGesture {
audioPlayer.setupPlayer(playback)
vm.showPlayer = true
}
Congratulations! You’re done with the front end! (Don’t forget to work on the backend!)
Run and test to verify and debug
You should now be able to run your front end against your backend. You will not get full credit if your front end is not set up to work with your backend!
Front-end submission guidelines
We will only grade files committed to the main branch. If you use multiple branches, please merge them all to the main branch for submission.
Push your front-end code to the same GitHub repo you’ve submitted your back-end code:
- Open GitHub Desktop and click on
Current Repositoryon the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - If you have pushed code to your repo, click
Pull Originto synch up the repo on your laptop - Finally click
Push Originto push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder audio. Confirm that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission and, further, you may have problems getting started on latter tutorials. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions in Preparing GitHub for Reactive Tutorials, only files needed for grading will be pushed to GitHub.
reactive
|-- audio
|-- swiftUIChatter
|-- swiftUIChatter.xcodeproj
|-- swiftUIChatter
|-- chatterd
|-- chatterd.crt
# and other files or folders
Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t build, run, or open.
IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.
Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous tutorial’s, please update your entry. If you’re using a different GitHub repo from previous tutorial’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.
References
| Prepared by Ollie Elmgren, Tiberiu Vilcu, Nowrin Mohamed, Xin Jie ‘Joyce’ Liu, Chenglin Li, and Sugih Jamin | Last updated: August 15th, 2025 |