Skip to content

ios: recover the stream after an audio session interruption - #1289

Closed
benface wants to merge 2 commits into
RustAudio:masterfrom
benface:ios-audio-session-interruption
Closed

ios: recover the stream after an audio session interruption#1289
benface wants to merge 2 commits into
RustAudio:masterfrom
benface:ios-audio-session-interruption

Conversation

@benface

@benface benface commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The problem

On iOS an audio session interruption — a call, an alarm, Siri — stops the audio unit through CoreAudio without going through cpal. StreamInner::playing is left reading true for a unit that is no longer running, so StreamTrait::play short-circuits:

if !stream.playing {
    stream.audio_unit.start()?;
    stream.playing = true;
}

An interrupted stream therefore can never be resumed for the lifetime of the process: play() returns Ok(()) and produces silence. AVAudioSessionInterruptionNotification is not among the notifications SessionEventManager watches, so nothing is reported either and the caller has no way to discover why. Reactivating the session with setActive:YES does not help on its own — the session comes back and the unit stays stopped.

The change

Observe AVAudioSessionInterruptionNotification alongside the route-change and media-services notifications already handled. On the beginning of an interruption, stop the stream through the normal path — which brings the flag back in step so a later play starts the unit again — and report a StreamInvalidated error.

Resuming stays the caller's decision, since Apple's guidance is that the app reads AVAudioSessionInterruptionOptionShouldResume, so the stream is left stopped rather than restarted here.

SessionEventManager is built before the Stream, so it takes a slot that Stream::new fills with a Weak<Mutex<StreamInner>>. The observer blocks already gate on the readiness latch, which is released later still, so the slot is always populated by the time one can act — reusing the existing ordering invariant rather than adding a second.

If you would rather not touch the state layout, a reporting-only variant is a much smaller diff, though callers would still be unable to recover.

Verification

Reproduced and confirmed on an iPhone against a shipping app: the stop-then-start this performs is what restores audio after an interruption.

  • cargo check --target aarch64-apple-ios clean
  • cargo clippy --target aarch64-apple-ios --lib clean
  • cargo fmt clean
  • The three --all-targets test errors on this target (default_output_device, OutputCallbackInfo, SampleRate) are pre-existing on master and unrelated

@benface

benface commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed end to end on an iPhone. The shipping app was pointed at this branch with its own pause()-before-play() workaround removed, so recovery had to come from cpal alone: after dismissing an alarm mid-session, a plain play() resumes audio. Without the patch the same call is a silent no-op.

@roderickvd roderickvd left a comment

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.

Thanks for tracking this down and verifying on real hardware, that's a real bug.

Per CONTRIBUTING.md I don't mind AI usage, but you do need to hold it to the same bar as hand-written code, and some of the comments here read unedited, see inline. Couple of points on the interruption handling itself too.

And if you would also add a short entry to CHANGELOG.md?

Comment thread src/host/coreaudio/ios/session_event_manager.rs
Comment thread src/host/coreaudio/ios/session_event_manager.rs Outdated
Comment thread src/host/coreaudio/ios/session_event_manager.rs Outdated
Comment thread src/host/coreaudio/ios/session_event_manager.rs Outdated
Comment thread src/host/coreaudio/ios/session_event_manager.rs Outdated
@benface
benface force-pushed the ios-audio-session-interruption branch 2 times, most recently from f2c568b to 02ce116 Compare July 26, 2026 21:46
A call, an alarm or Siri stops the audio unit through CoreAudio
without going through cpal, so `StreamInner::playing` is left
reading `true` for a unit that is no longer running. `play` then
short-circuits on that flag, and the stream can never be resumed:
the caller sees a successful `play` and silence, with no error to
explain it.

`AVAudioSessionInterruptionNotification` is now observed alongside
the route-change and media-services notifications the manager
already handles. Beginning an interruption stops the unit through
the normal path, bringing the flag back in step with reality. When
one ends carrying `ShouldResume`, the session is reactivated — it
is left inactive, and a unit cannot start against an inactive
session — and the unit started again, so a caller does not have to
observe the notification itself just to get its stream back. Only
streams the interruption stopped resume; one the caller had paused
stays paused.

Confirmed on device: an app pointed at this branch, with its own
restart workaround and its interruption observer both removed,
recovers its audio after an alarm is dismissed.
@benface
benface force-pushed the ios-audio-session-interruption branch from 02ce116 to 079ea89 Compare July 26, 2026 21:46
@benface

benface commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @roderickvd, you were right about Ended. Handling only Began meant every caller still had to observe the notification themselves.

Updated:

  • Ended reads AVAudioSessionInterruptionOptionKey and, when ShouldResume is set, reactivates the session and starts the unit again from the same closure. Only streams the interruption stopped resume, so one the caller had paused stays paused.
  • Dropped the OnceLock / attach_stream two-step. Both builders create the Arc<Mutex<StreamInner>> first and pass Arc::downgrade into SessionEventManager::new.
  • Dropped the emit_error, and the StreamInvalidated kind with it.
  • Moved the use crate::{...} block up with the other imports, trimmed the two comments, added a CHANGELOG entry.

Also folded the two different ways of reading userInfo into one helper, and inlined Stream::new now that it does not do anything.

Confirmed on device again: app pointed at this branch with both its own workaround and its interruption observer removed, so recovery had to come from cpal alone. Music comes back after dismissing an alarm.

@roderickvd roderickvd left a comment

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.

Thanks for processing the previous round. Having worked through the various states, I think we need some further changes to prevent race conditions.


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.

let mut stream = self.inner.lock().map_err(|_| {
Error::with_message(ErrorKind::StreamInvalidated, "Stream lock poisoned")
})?;
if stream.playing {

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.

pause now needs to set stream.interrupted = false. Otherwise, calling pause during an interruption would have the end-of-interruption event restart the stream when you don't want to.

}

/// 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?

Comment thread CHANGELOG.md
- **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."

struct StreamInner {
playing: bool,
/// Set while an interruption is what stopped the unit, so only those resume on its end.
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.

@roderickvd

Copy link
Copy Markdown
Member

Superseded by #1295. I want to have it included in 0.18.2 that I want to release momentarily. I'll keep you as co-author @benface, thanks for the initial report and work.

@roderickvd roderickvd closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants