Skip to content

Commit 7a2e3ea

Browse files
committed
Make audio controller an actor
1 parent 4bdeea4 commit 7a2e3ea

File tree

2 files changed

+39
-22
lines changed

2 files changed

+39
-22
lines changed

firebaseai/LiveAudioExample/Audio/AudioController.swift

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
import AVFoundation
1616

1717
/// Controls audio playback and recording.
18-
class AudioController {
18+
actor AudioController {
1919
/// Data processed from the microphone.
2020
private let microphoneData: AsyncStream<AVAudioPCMBuffer>
2121
private let microphoneDataQueue: AsyncStream<AVAudioPCMBuffer>.Continuation
2222
private var audioPlayer: AudioPlayer?
2323
private var audioEngine: AVAudioEngine?
2424
private var microphone: Microphone?
2525
private var listenTask: Task<Void, Never>?
26+
private var routeTask: Task<Void, Never>?
2627

2728
/// Port types that are considered "headphones" for our use-case.
2829
///
@@ -40,7 +41,7 @@ class AudioController {
4041

4142
private var stopped = false
4243

43-
public init() throws {
44+
public init() async throws {
4445
let session = AVAudioSession.sharedInstance()
4546
try session.setCategory(
4647
.playAndRecord,
@@ -80,7 +81,25 @@ class AudioController {
8081
}
8182

8283
deinit {
83-
stop()
84+
stopped = true
85+
listenTask?.cancel()
86+
// audio engine needs to be stopped before disconnecting nodes
87+
audioEngine?.pause()
88+
audioEngine?.stop()
89+
if let audioEngine {
90+
do {
91+
// the VP IO leaves behind artifacts, so we need to disable it to properly clean up
92+
if audioEngine.inputNode.isVoiceProcessingEnabled {
93+
try audioEngine.inputNode.setVoiceProcessingEnabled(false)
94+
}
95+
} catch {
96+
print("Failed to disable voice processing: \(error.localizedDescription)")
97+
}
98+
}
99+
microphone?.stop()
100+
audioPlayer?.stop()
101+
microphoneDataQueue.finish()
102+
routeTask?.cancel()
84103
}
85104

86105
/// Kicks off audio processing, and returns a stream of recorded microphone audio data.
@@ -96,11 +115,7 @@ class AudioController {
96115
stopped = true
97116
stopListeningAndPlayback()
98117
microphoneDataQueue.finish()
99-
NotificationCenter.default.removeObserver(
100-
self,
101-
name: AVAudioSession.routeChangeNotification,
102-
object: nil
103-
)
118+
routeTask?.cancel()
104119
}
105120

106121
/// Queues audio for playback.
@@ -206,15 +221,17 @@ class AudioController {
206221

207222
/// When the output device changes, ensure the audio playback and recording classes are properly restarted.
208223
private func listenForRouteChange() {
209-
NotificationCenter.default.addObserver(
210-
self,
211-
selector: #selector(handleRouteChange),
212-
name: AVAudioSession.routeChangeNotification,
213-
object: nil
214-
)
224+
routeTask?.cancel()
225+
routeTask = Task { [weak self] in
226+
for await notification in NotificationCenter.default.notifications(
227+
named: AVAudioSession.routeChangeNotification
228+
) {
229+
await self?.handleRouteChange(notification: notification)
230+
}
231+
}
215232
}
216233

217-
@objc private func handleRouteChange(notification: Notification) {
234+
private func handleRouteChange(notification: Notification) {
218235
guard let userInfo = notification.userInfo,
219236
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
220237
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {

firebaseai/LiveAudioExample/ViewModels/LiveViewModel.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ class LiveViewModel: ObservableObject {
9494

9595
do {
9696
liveSession = try await model.connect()
97-
audioController = try AudioController()
97+
audioController = try await AudioController()
9898

99-
try startRecording()
99+
try await startRecording()
100100

101101
state = .connected
102102
try await startProcessingResponses()
@@ -111,7 +111,7 @@ class LiveViewModel: ObservableObject {
111111
///
112112
/// Will stop any pending playback, and the recording of the mic.
113113
func disconnect() async {
114-
audioController?.stop()
114+
await audioController?.stop()
115115
await liveSession?.close()
116116
microphoneTask.cancel()
117117
state = .idle
@@ -124,10 +124,10 @@ class LiveViewModel: ObservableObject {
124124
}
125125

126126
/// Starts recording data from the user's microphone, and sends it to the model.
127-
private func startRecording() throws {
127+
private func startRecording() async throws {
128128
guard let audioController, let liveSession else { return }
129129

130-
let stream = try audioController.listenToMic()
130+
let stream = try await audioController.listenToMic()
131131
microphoneTask = Task {
132132
for await audioBuffer in stream {
133133
await liveSession.sendAudioRealtime(audioBuffer.int16Data())
@@ -190,7 +190,7 @@ class LiveViewModel: ObservableObject {
190190

191191
if content.wasInterrupted {
192192
logger.warning("Model was interrupted")
193-
audioController?.interrupt()
193+
await audioController?.interrupt()
194194
transcriptViewModel.clearPending()
195195
// adds an em dash to indiciate that the model was cutoff
196196
transcriptViewModel.appendTranscript("")
@@ -203,7 +203,7 @@ class LiveViewModel: ObservableObject {
203203
for part in content.parts {
204204
if let part = part as? InlineDataPart {
205205
if part.mimeType.starts(with: "audio/pcm") {
206-
audioController?.playAudio(audio: part.data)
206+
await audioController?.playAudio(audio: part.data)
207207
} else {
208208
logger.warning("Received non audio inline data part: \(part.mimeType)")
209209
}

0 commit comments

Comments
 (0)