Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **AudioWorklet**: Fix stale audio output when a data callback wrote a partial buffer.
- **CoreAudio**: Default-output streams now report xrun status.
- **CoreAudio**: Fix stale audio output when a data callback wrote a partial buffer.
- **iOS**: Streams stopped by an audio session interruption now resume when it ends, instead of staying silent until rebuilt.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be shorter, like: "Streams now resume automatically when an audio session interruption ends."

- **JACK**: Streams with more channels than physical system ports no longer fail to build.
- **JACK**: Channel enumeration is no longer capped at the physical system port count.
- **JACK**: `Device::id` no longer embeds the process ID, so `DeviceId` is now stable across application restarts.
Expand Down
87 changes: 57 additions & 30 deletions src/host/coreaudio/ios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,8 @@ impl DeviceTrait for Device {
let latency_frames = Arc::new(AtomicUsize::new(input_latency_frames()));

let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback));
let session_manager = SessionEventManager::new(
error_callback.clone(),
Latch::new(),
Some((latency_frames.clone(), true)),
);
let session_error_callback = error_callback.clone();
let session_latency_frames = latency_frames.clone();

// Set up input callback
setup_input_callback(
Expand All @@ -208,13 +205,21 @@ impl DeviceTrait for Device {
},
)?;

let stream = Stream::new(
StreamInner {
playing: false,
audio_unit,
},
session_manager,
let inner = Arc::new(Mutex::new(StreamInner {
playing: false,
interrupted: false,
audio_unit,
}));
let session_manager = SessionEventManager::new(
session_error_callback,
Latch::new(),
Some((session_latency_frames, true)),
Arc::downgrade(&inner),
);
let stream = Stream {
inner,
session_manager,
};
stream.signal_ready();
Ok(stream)
}
Expand Down Expand Up @@ -245,11 +250,8 @@ impl DeviceTrait for Device {
let latency_frames = Arc::new(AtomicUsize::new(output_latency_frames()));

let error_callback: ErrorCallbackArc = Arc::new(Mutex::new(error_callback));
let session_manager = SessionEventManager::new(
error_callback.clone(),
Latch::new(),
Some((latency_frames.clone(), false)),
);
let session_error_callback = error_callback.clone();
let session_latency_frames = latency_frames.clone();

// Set up output callback
setup_output_callback(
Expand All @@ -263,31 +265,32 @@ impl DeviceTrait for Device {
},
)?;

let stream = Stream::new(
StreamInner {
playing: false,
audio_unit,
},
session_manager,
let inner = Arc::new(Mutex::new(StreamInner {
playing: false,
interrupted: false,
audio_unit,
}));
let session_manager = SessionEventManager::new(
session_error_callback,
Latch::new(),
Some((session_latency_frames, false)),
Arc::downgrade(&inner),
);
let stream = Stream {
inner,
session_manager,
};
stream.signal_ready();
Ok(stream)
}
}

pub struct Stream {
inner: Mutex<StreamInner>,
inner: Arc<Mutex<StreamInner>>,
session_manager: SessionEventManager,
}

impl Stream {
fn new(inner: StreamInner, session_manager: SessionEventManager) -> Self {
Self {
inner: Mutex::new(inner),
session_manager,
}
}

fn signal_ready(&self) {
self.session_manager.signal_ready();
}
Expand Down Expand Up @@ -341,9 +344,33 @@ impl StreamTrait for Stream {

struct StreamInner {
playing: bool,
/// Set while an interruption is what stopped the unit, so only those resume on its end.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove comments where the code is self-explanatory.

interrupted: bool,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of an interrupted field next to playing, we should probably use an enum like:

enum PlaybackState {
    Stopped,
    Playing,
    Interrupted,
}

This will also prevent race conditions when one flag is written, the other not yet, and one of the closures is called.

audio_unit: AudioUnit,
}

impl StreamInner {
/// The OS already stopped the unit; resync `playing` so a later `play` actually starts it.
fn stop_for_interruption(&mut self) {
if !self.playing {
return;
}
let _ = self.audio_unit.stop();
self.playing = false;
self.interrupted = true;
}

/// Only resumes what the interruption stopped; a stream the caller had paused stays paused.
fn resume_after_interruption(&mut self) {
if !std::mem::take(&mut self.interrupted) {
return;
}
if self.audio_unit.start().is_ok() {
self.playing = true;
}
}
}

fn create_audio_unit() -> Result<AudioUnit, coreaudio::Error> {
AudioUnit::new_uninitialized(coreaudio::audio_unit::IOType::RemoteIO)
}
Expand Down
76 changes: 65 additions & 11 deletions src/host/coreaudio/ios/session_event_manager.rs
Original file line number Diff line number Diff line change
@@ -1,39 +1,54 @@
//! Monitors AVAudioSession lifecycle events and reports them as stream errors.
//! Monitors AVAudioSession lifecycle events, recovering the stream from an interruption and
//! reporting the rest as stream errors.

use std::{
ptr::NonNull,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
Arc, Mutex, Weak,
},
};

use block2::RcBlock;
use objc2::runtime::AnyObject;
use objc2_avf_audio::{
AVAudioSessionMediaServicesWereLostNotification,
AVAudioSession, AVAudioSessionInterruptionNotification, AVAudioSessionInterruptionOptionKey,
AVAudioSessionInterruptionOptions, AVAudioSessionInterruptionType,
AVAudioSessionInterruptionTypeKey, AVAudioSessionMediaServicesWereLostNotification,
AVAudioSessionMediaServicesWereResetNotification, AVAudioSessionRouteChangeNotification,
AVAudioSessionRouteChangeReason, AVAudioSessionRouteChangeReasonKey,
};
use objc2_foundation::{NSNotification, NSNotificationCenter, NSNumber, NSString};

use super::{input_latency_frames, output_latency_frames};
use super::{input_latency_frames, output_latency_frames, StreamInner};
use crate::{
Comment thread
benface marked this conversation as resolved.
host::{emit_error, latch::Latch, ErrorCallbackArc},
Error, ErrorKind,
};

/// Runs `f` against the stream, if it is still alive and its lock is intact.
fn with_stream(stream: &Weak<Mutex<StreamInner>>, f: impl FnOnce(&mut StreamInner)) {
if let Some(inner) = stream.upgrade() {
if let Ok(mut inner) = inner.lock() {
f(&mut inner);
}
}
}

/// Reads the number stored under `key` in a notification's `userInfo`.
unsafe fn user_info_number(notification: &NSNotification, key: Option<&NSString>) -> Option<usize> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this doesn't need unsafe any longer?

let user_info = notification.userInfo()?;
let value = user_info.objectForKey(key?)?;
Some(value.downcast::<NSNumber>().ok()?.unsignedIntegerValue())
}

/// Shared buffer-depth value to refresh on route changes, paired with `is_input` to select the
/// input or output latency. `true` means an input stream.
type LatencyRefresh = (Arc<AtomicUsize>, bool);

unsafe fn route_change_error(notification: &NSNotification) -> Option<Error> {
let user_info = notification.userInfo()?;
let key = AVAudioSessionRouteChangeReasonKey?;
let dict = unsafe { user_info.cast_unchecked::<NSString, AnyObject>() };
let value = dict.objectForKey(key)?;
let number = value.downcast_ref::<NSNumber>()?;
let reason = AVAudioSessionRouteChangeReason(number.unsignedIntegerValue());
let reason = AVAudioSessionRouteChangeReason(unsafe {
user_info_number(notification, AVAudioSessionRouteChangeReasonKey)?
});
match reason {
AVAudioSessionRouteChangeReason::OldDeviceUnavailable => Some(Error::with_message(
ErrorKind::DeviceChanged,
Expand Down Expand Up @@ -73,11 +88,50 @@ impl SessionEventManager {
error_callback: ErrorCallbackArc,
latch: Latch,
latency_refresh: Option<LatencyRefresh>,
stream: Weak<Mutex<StreamInner>>,
) -> Self {
let nc = NSNotificationCenter::defaultCenter();
let mut observers = Vec::new();
let waiter = latch.waiter();

// The OS stops the unit itself, and the session it stops is inactive on the way back.
{
let w = waiter.clone();
let stream = stream.clone();
let block = RcBlock::new(move |notif: NonNull<NSNotification>| {
if !w.is_released() {
return;
}
let notif = unsafe { notif.as_ref() };
let Some(kind) =
(unsafe { user_info_number(notif, AVAudioSessionInterruptionTypeKey) })
else {
return;
};
if AVAudioSessionInterruptionType(kind) == AVAudioSessionInterruptionType::Began {
with_stream(&stream, StreamInner::stop_for_interruption);
return;
}
let options = AVAudioSessionInterruptionOptions(
unsafe { user_info_number(notif, AVAudioSessionInterruptionOptionKey) }
.unwrap_or(0),
);
if !options.contains(AVAudioSessionInterruptionOptions::ShouldResume) {
return;
}
let session = unsafe { AVAudioSession::sharedInstance() };
if unsafe { session.setActive_error(true) }.is_ok() {
with_stream(&stream, StreamInner::resume_after_interruption);
}
});
if let Some(name) = unsafe { AVAudioSessionInterruptionNotification } {
let observer = unsafe {
nc.addObserverForName_object_queue_usingBlock(Some(name), None, None, &block)
};
observers.push(observer);
}
}

{
let cb = error_callback.clone();
let w = waiter.clone();
Expand Down
Loading