Skip to content

Commit 29131bc

Browse files
committed
feat(coreaudio): fire error callback on iOS AVAudioSession events
1 parent 57d90ab commit 29131bc

File tree

4 files changed

+176
-21
lines changed

4 files changed

+176
-21
lines changed

CHANGELOG.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
silently returning an empty list.
3434
- **AAudio**: Bump MSRV to 1.85.
3535
- **AAudio**: Buffers with default sizes are now dynamically tuned.
36-
- **ALSA**: Device disconnection now stops the stream with `StreamError::DeviceNotAvailable`
36+
- **ALSA**: Device disconnection now stops the stream with `StreamError::DeviceNotAvailable`
3737
instead of looping.
3838
- **ALSA**: Polling errors trigger underrun recovery instead of looping.
3939
- **ALSA**: Try to resume from hardware after a system suspend.
@@ -50,10 +50,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5050
- **CoreAudio**: Timestamps now include device latency and safety offset.
5151
- **CoreAudio**: Poisoned stream mutex in stream functions now propagate panics.
5252
- **CoreAudio**: Physical stream format is now set directly on the hardware device.
53-
- **CoreAudio**: Stream error callback now receives `StreamError::StreamInvalidated` on any sample
54-
rate change.
53+
- **CoreAudio**: Stream error callback now receives `StreamError::StreamInvalidated` on any sample
54+
rate change on macOS, and on iOS on route changes that require a stream rebuild.
55+
- **CoreAudio**: Stream error callback now receives `StreamError::DeviceNotAvailable` on iOS
56+
when media services are lost.
5557
- **JACK**: Timestamps now use the precise hardware deadline.
56-
- **JACK**: Buffer size change no longer fires an error callback; internal buffers are resized
58+
- **JACK**: Buffer size change no longer fires an error callback; internal buffers are resized
5759
without error.
5860
- **JACK**: Server shutdown now fires `StreamError::DeviceNotAvailable`.
5961
- **Linux/BSD**: Default host in order from first to last available now is: PipeWire, PulseAudio,
@@ -85,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8587
- **ASIO**: Fix latency not updating when the driver reports `kAsioLatenciesChanged`.
8688
- **ASIO**: Fix distortion when buggy drivers fire the buffer callback multiple times per cycle.
8789
- **ASIO**: Poisoned error callback mutex no longer silently drops subsequent error notifications.
88-
- **ASIO**: Poisoned stream mutex in the buffer-size change handler no longer silently skips the
90+
- **ASIO**: Poisoned stream mutex in the buffer-size change handler no longer silently skips the
8991
update.
9092
- **CoreAudio**: Fix undefined behaviour and silent failure in loopback device creation.
9193
- **Emscripten**: Fix build failure introduced by newer `wasm-bindgen` versions.

Cargo.toml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,29 @@ objc2-core-audio-types = { version = "0.3", default-features = false, features =
136136
"CoreAudioBaseTypes",
137137
] }
138138
objc2-core-foundation = { version = "0.3" }
139-
objc2-foundation = { version = "0.3" }
139+
objc2-foundation = { version = "0.3", default-features = false, features = [
140+
"std",
141+
"NSArray",
142+
"NSString",
143+
"NSValue",
144+
] }
140145
objc2 = { version = "0.6" }
141146

142147
[target.'cfg(target_os = "macos")'.dependencies]
143148
jack = { version = "0.13", optional = true }
144149

145150
[target.'cfg(target_os = "ios")'.dependencies]
151+
block2 = "0.6"
152+
objc2-foundation = { version = "0.3", features = [
153+
"block2",
154+
"NSDictionary",
155+
"NSNotification",
156+
"NSOperation",
157+
] }
146158
objc2-avf-audio = { version = "0.3", default-features = false, features = [
147159
"std",
148160
"AVAudioSession",
161+
"AVAudioSessionTypes",
149162
] }
150163

151164
[target.'cfg(target_os = "emscripten")'.dependencies]

src/host/coreaudio/ios/mod.rs

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
//! CoreAudio implementation for iOS using AVAudioSession and RemoteIO Audio Units.
22
3+
use std::ptr::NonNull;
4+
use std::sync::Arc;
35
use std::sync::Mutex;
6+
use std::time::Duration;
47

58
use coreaudio::audio_unit::render_callback::data;
69
use coreaudio::audio_unit::{render_callback, AudioUnit, Element, Scope};
710
use objc2_audio_toolbox::{kAudioOutputUnitProperty_EnableIO, kAudioUnitProperty_StreamFormat};
8-
use objc2_core_audio_types::AudioBuffer;
9-
1011
use objc2_avf_audio::AVAudioSession;
12+
use objc2_core_audio_types::AudioBuffer;
1113

1214
use super::{asbd_from_config, frames_to_duration, host_time_to_stream_instant};
1315
use crate::traits::{DeviceTrait, HostTrait, StreamTrait};
@@ -25,10 +27,10 @@ use self::enumerate::{
2527
default_input_device, default_output_device, Devices, SupportedInputConfigs,
2628
SupportedOutputConfigs,
2729
};
28-
use std::ptr::NonNull;
29-
use std::time::Duration;
3030

3131
pub mod enumerate;
32+
mod session_event_manager;
33+
use session_event_manager::{ErrorCallbackMutex, SessionEventManager};
3234

3335
// These days the default of iOS is now F32 and no longer I16
3436
const SUPPORTED_SAMPLE_FORMAT: SampleFormat = SampleFormat::F32;
@@ -169,22 +171,32 @@ impl DeviceTrait for Device {
169171
// Query device buffer size for latency calculation
170172
let device_buffer_frames = Some(get_device_buffer_frames());
171173

174+
let error_callback: ErrorCallbackMutex = Arc::new(Mutex::new(Box::new(error_callback)));
175+
let session_manager = SessionEventManager::new(error_callback.clone());
176+
172177
// Set up input callback
173178
setup_input_callback(
174179
&mut audio_unit,
175180
sample_format,
176181
config.sample_rate,
177182
device_buffer_frames,
178183
data_callback,
179-
error_callback,
184+
move |e| {
185+
if let Ok(mut cb) = error_callback.lock() {
186+
cb(e);
187+
}
188+
},
180189
)?;
181190

182191
audio_unit.start()?;
183192

184-
Ok(Stream::new(StreamInner {
185-
playing: true,
186-
audio_unit,
187-
}))
193+
Ok(Stream::new(
194+
StreamInner {
195+
playing: true,
196+
audio_unit,
197+
},
198+
session_manager,
199+
))
188200
}
189201

190202
/// Create an output stream.
@@ -206,33 +218,45 @@ impl DeviceTrait for Device {
206218
// Query device buffer size for latency calculation
207219
let device_buffer_frames = Some(get_device_buffer_frames());
208220

221+
let error_callback: ErrorCallbackMutex = Arc::new(Mutex::new(Box::new(error_callback)));
222+
let session_manager = SessionEventManager::new(error_callback.clone());
223+
209224
// Set up output callback
210225
setup_output_callback(
211226
&mut audio_unit,
212227
sample_format,
213228
config.sample_rate,
214229
device_buffer_frames,
215230
data_callback,
216-
error_callback,
231+
move |e| {
232+
if let Ok(mut cb) = error_callback.lock() {
233+
cb(e);
234+
}
235+
},
217236
)?;
218237

219238
audio_unit.start()?;
220239

221-
Ok(Stream::new(StreamInner {
222-
playing: true,
223-
audio_unit,
224-
}))
240+
Ok(Stream::new(
241+
StreamInner {
242+
playing: true,
243+
audio_unit,
244+
},
245+
session_manager,
246+
))
225247
}
226248
}
227249

228250
pub struct Stream {
229251
inner: Mutex<StreamInner>,
252+
_session_manager: SessionEventManager,
230253
}
231254

232255
impl Stream {
233-
fn new(inner: StreamInner) -> Self {
256+
fn new(inner: StreamInner, session_manager: SessionEventManager) -> Self {
234257
Self {
235258
inner: Mutex::new(inner),
259+
_session_manager: session_manager,
236260
}
237261
}
238262
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! Monitors AVAudioSession lifecycle events and reports them as stream errors.
2+
3+
use std::ptr::NonNull;
4+
use std::sync::{Arc, Mutex};
5+
6+
use block2::RcBlock;
7+
use objc2::runtime::AnyObject;
8+
use objc2_avf_audio::{
9+
AVAudioSessionMediaServicesWereLostNotification,
10+
AVAudioSessionMediaServicesWereResetNotification, AVAudioSessionRouteChangeNotification,
11+
AVAudioSessionRouteChangeReason, AVAudioSessionRouteChangeReasonKey,
12+
};
13+
use objc2_foundation::{NSNotification, NSNotificationCenter, NSNumber, NSString};
14+
15+
use crate::StreamError;
16+
17+
pub(super) type ErrorCallbackMutex = Arc<Mutex<Box<dyn FnMut(StreamError) + Send>>>;
18+
19+
unsafe fn route_change_error(notification: &NSNotification) -> Option<StreamError> {
20+
let user_info = notification.userInfo()?;
21+
let key = AVAudioSessionRouteChangeReasonKey?;
22+
let dict = unsafe { user_info.cast_unchecked::<NSString, AnyObject>() };
23+
let value = dict.objectForKey(key)?;
24+
let number = value.downcast_ref::<NSNumber>()?;
25+
let reason = AVAudioSessionRouteChangeReason(number.unsignedIntegerValue());
26+
match reason {
27+
AVAudioSessionRouteChangeReason::OldDeviceUnavailable
28+
| AVAudioSessionRouteChangeReason::CategoryChange
29+
| AVAudioSessionRouteChangeReason::Override
30+
| AVAudioSessionRouteChangeReason::RouteConfigurationChange => {
31+
Some(StreamError::StreamInvalidated)
32+
}
33+
34+
AVAudioSessionRouteChangeReason::NoSuitableRouteForCategory => {
35+
Some(StreamError::DeviceNotAvailable)
36+
}
37+
38+
_ => None,
39+
}
40+
}
41+
42+
pub(super) struct SessionEventManager {
43+
observers: Vec<
44+
objc2::rc::Retained<objc2::runtime::ProtocolObject<dyn objc2::runtime::NSObjectProtocol>>,
45+
>,
46+
}
47+
48+
// SAFETY: NSNotificationCenter is thread-safe on iOS. The observer tokens stored here are opaque
49+
// handles used only to call removeObserver in Drop; no data is read or written through them.
50+
unsafe impl Send for SessionEventManager {}
51+
unsafe impl Sync for SessionEventManager {}
52+
53+
impl SessionEventManager {
54+
pub(super) fn new(error_callback: ErrorCallbackMutex) -> Self {
55+
let nc = NSNotificationCenter::defaultCenter();
56+
let mut observers = Vec::new();
57+
58+
{
59+
let cb = error_callback.clone();
60+
let block = RcBlock::new(move |notif: NonNull<NSNotification>| {
61+
if let Some(err) = unsafe { route_change_error(notif.as_ref()) } {
62+
if let Ok(mut cb) = cb.lock() {
63+
cb(err);
64+
}
65+
}
66+
});
67+
if let Some(name) = unsafe { AVAudioSessionRouteChangeNotification } {
68+
let observer = unsafe {
69+
nc.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block)
70+
};
71+
observers.push(observer);
72+
}
73+
}
74+
75+
{
76+
let cb = error_callback.clone();
77+
let block = RcBlock::new(move |_: NonNull<NSNotification>| {
78+
if let Ok(mut cb) = cb.lock() {
79+
cb(StreamError::DeviceNotAvailable);
80+
}
81+
});
82+
if let Some(name) = unsafe { AVAudioSessionMediaServicesWereLostNotification } {
83+
let observer = unsafe {
84+
nc.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block)
85+
};
86+
observers.push(observer);
87+
}
88+
}
89+
90+
{
91+
let cb = error_callback.clone();
92+
let block = RcBlock::new(move |_: NonNull<NSNotification>| {
93+
if let Ok(mut cb) = cb.lock() {
94+
cb(StreamError::StreamInvalidated);
95+
}
96+
});
97+
if let Some(name) = unsafe { AVAudioSessionMediaServicesWereResetNotification } {
98+
let observer = unsafe {
99+
nc.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block)
100+
};
101+
observers.push(observer);
102+
}
103+
}
104+
105+
Self { observers }
106+
}
107+
}
108+
109+
impl Drop for SessionEventManager {
110+
fn drop(&mut self) {
111+
let nc = NSNotificationCenter::defaultCenter();
112+
for observer in &self.observers {
113+
unsafe { nc.removeObserver(observer.as_ref()) };
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)