Skip to content

0.20.0 - Host-only audio-session ownership#99

Merged
briankeane merged 23 commits into
mainfrom
develop
Jun 11, 2026
Merged

0.20.0 - Host-only audio-session ownership#99
briankeane merged 23 commits into
mainfrom
develop

Conversation

@briankeane

Copy link
Copy Markdown
Collaborator

Release 0.20.0developmain.

Pre-1.0 minor bump (SemVer: breaking changes bump the minor version below 1.0).

Theme

Host-only audio-session ownership. The SDK no longer manages the AVAudioSession; the host app owns it and drives interruption transport. See the 0.20.0 CHANGELOG entry for the full migration guide.

PRs included

Highlights

  • Breaking: SDK deleted all AVAudioSession management (AudioSessionManager gone; configureAudioSession/ensureAudioSessionConfigured/deactivateAudioSession removed; no interruption/route observers). Host must configure + activate the session and drive interruptions.
  • Added: pauseForInterruption() / resumeAfterInterruption() async throws, new State.paused(Spin), and engine self-recovery scoped to the SDK's own engine.
  • Migration steps and host setup examples in the README "Audio session" section + the CHANGELOG.

Version Bump

  • Previous: 0.19.0
  • New: 0.20.0
  • Type: minor (pre-1.0 breaking)

After merge

Tag the merge commit on main as 0.20.0 and push the tag — that's what SPM consumers pin to. (Consistent with how 0.19.0 was cut.)

briankeane added 17 commits June 9, 2026 20:30
- configure() no longer resolves the shared mixer on the default path
  (.sdkOwned, no injection) — develop's configure never touched the mixer,
  and resolving .shared builds the CoreAudio graph, which hangs headless
  CircleCI runs that merely construct + configure a player
- gate AudioSessionOwnershipTests behind CI env check: every test in the
  suite constructs PlayolaMainMixer (same failure class as e8604a5)
- guard handleAudioEngineConfigurationChange on handlesSessionEventsInternally:
  auto-resume is interruption policy and must not race a host-driven resume
  (greptile outside-diff #1)
- pauseForInterruption cancels playTask for symmetry with stop()
  (greptile outside-diff #2)
- seam test: graceful skip when sources aren't present at runtime
  (compile-time #filePath; greptile outside-diff #4)
- lint: file_length disable pair on PlayolaStationPlayer.swift (matches
  SpinPlayer precedent), for-where + line length in seam test
The SDK no longer manages the AVAudioSession at all. Removes the
.sdkOwned/.hostOwned dual mode and all of its transitional machinery,
which was the source of repeated review churn (split-brain release
fallback, late-application ordering, double-configure divergence) and
had no value given a single consumer that migrates in lockstep.

Deleted: PlayolaAudioSessionOwnership, AudioSessionManager +
NoOpAudioSessionManager + AudioSessionManaging (whole file),
applyOwnership/appliedOwnership/sessionTouched/hasAppliedOwnershipToMixer,
handlesSessionEventsInternally, observer gating, and the legacy
interruption/route/engine-config handlers (removing the two known
pre-existing legacy bugs with them). configure() loses its
audioSessionOwnership parameter.

The host now owns the AVAudioSession: it configures/activates it and
drives pauseForInterruption()/resumeAfterInterruption() from its own
interruption handlers. SpinPlayer/PlayolaMainMixer no longer touch the
session; engine.start() throwing on an inactive session surfaces through
the normal error path.

Unchanged: pauseForInterruption/resumeAfterInterruption/.paused, lazy
mixer resolution, stale-download guard. The seam invariant test now
asserts the SDK references AVAudioSession nowhere in Sources/. Example
app gains its own session setup. README documents the host contract and
a 0.19->0.20 migration guide.

BREAKING CHANGE: host apps must own the AVAudioSession (see README
migration guide). Ships as 0.20.0.
…ure (review)

Adversarial review (Codex challenge) findings on the host-only rework:

- [P1] resumeAfterInterruption() cleared the armed resume token in a defer
  even when restartEngine()/play() threw, permanently disarming retry. Now
  disarms only after a successful resume; a failed resume (e.g. host hasn't
  reactivated the session yet) stays armed for the next attempt. (This was
  unsafe to do under the old dual-mode config-change observer; that observer
  is gone, so preserving the token can no longer cause a double-resume.)

- [P1] removing the engine-config observer left no recovery when AVAudioEngine
  stops itself on a hardware/format/route reconfiguration — silent dead audio
  while state stayed .playing. Re-added a PURE engine-recovery observer
  (AVAudioEngineConfigurationChange, not an AVAudioSession API — seam intact):
  restarts the engine and re-syncs to wall clock, guarded to skip while
  host-paused or not playing. object: nil avoids resolving the shared mixer at
  construction (no CI hang). This is engine ownership, not session ownership.

- README: corrected the stale 'session management' architecture bullet.
- Example app: demonstrates the full host contract (interruption observer
  driving pause/resume), not just launch-time activation.

Accepted as follow-ups (noted on PR): cold-start engine-start failure is
retried like a transient download failure and surfaces as .scheduleError
(only reachable when the host violates the activate-before-play contract,
caught at dev time); the seam test is a substring tripwire, not a complete
invariant (documented in-test).
…o own engine

- Add CHANGELOG 0.20.0 entry (pre-1.0 breaking = minor bump) documenting the
  host-owns-AVAudioSession change + migration steps, in the project's style.
- Scope the AVAudioEngineConfigurationChange observer to the SDK's OWN engine
  (registered lazily on first playback) instead of object: nil. Fixes the
  Greptile 4/5 concern: a host running its own AVAudioEngine no longer triggers
  spurious SDK restartEngine()+play() on unrelated config changes. Lazy
  registration keeps construction from building the CoreAudio graph (no CI hang).
- README migration guide: drop the phantom 'audioSessionOwnership parameter'
  step (that param never shipped in a release) — configure() is otherwise
  unchanged.
…level-audio

refactor!: host-only audio-session ownership + host-driven pause/resume
@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown

Greptile Summary

This release (0.20.0) removes all AVAudioSession management from the SDK — AudioSessionManager is deleted, PlayolaMainMixer loses its three session methods, and PlayolaStationPlayer drops its interruption/route-change observers. The host app now owns the session entirely and drives transport via two new public methods, pauseForInterruption() and resumeAfterInterruption(), backed by a new .paused(Spin) state. All three issues flagged in the prior review (generation gate, .error state publication on restartEngine() failure, and @objc public visibility) are correctly resolved in the included commits (#100, #101).

  • Removed: AudioSessionManager, configureAudioSession/ensureAudioSessionConfigured/deactivateAudioSession, all AVAudioSession observers. SessionSeamInvariantTests enforces this contract structurally.
  • Added: pauseForInterruption() / resumeAfterInterruption() async throws, .paused(Spin) state, engine self-recovery on AVAudioEngineConfigurationChange (scoped to the SDK's own engine, generation-gated, surfacing .error on failure), and InterruptionTransportTests covering all pause/resume/stop edge cases.
  • Example app: HostAudioSession demonstrates the full host contract — session configure+activate, interruption observation, and bidirectional SDK transport calls — with correct observer lifecycle management.

Confidence Score: 5/5

Safe to merge. All three defects raised in prior review rounds are correctly addressed: the generation gate, .error state publication on engine-restart failure, and internal-only visibility for handleAudioEngineConfigurationChange. The two remaining notes are minor housekeeping items that do not affect correctness for singleton usage (the production path).

The core logic — generation-gated recovery, one-shot interruption consumption, stop() clearing armed state, stale-download guard in SpinPlayer — is sound and covered by the new InterruptionTransportTests. The SessionSeamInvariantTests enforce the host-session-ownership contract structurally. The only new findings are a missing removeObserver in deinit (harmless for the singleton, only observable via non-production test patterns) and double Sentry reporting when play() fails inside the config-change recovery Task.

The deinit in PlayolaStationPlayer.swift should add removeObserver(engineConfigObserver) to keep the class safe for non-singleton use. The playola-radio-ios companion repo requires .paused arms in both its StationPlayer.processPlayolaStationPlayerState and NowPlayingUpdater.processPlayolaStationPlayerState switches before this SDK version is consumed there.

Important Files Changed

Filename Overview
Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Major refactor: removes all AVAudioSession management, adds pauseForInterruption()/resumeAfterInterruption(), a new .paused(Spin) state, and engine-config self-recovery. All previously flagged issues (generation gate, .error publishing, @objc public visibility) are correctly fixed. Two minor concerns: engineConfigObserver token is not removed in deinit (observable for non-singleton instances), and play() errors are double-reported to Sentry in the config-change recovery catch block.
Sources/PlayolaPlayer/Player/AudioSessionManager.swift Deleted in full — all AVAudioSession management responsibility transferred to the host app as intended by this release.
Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift All three AVAudioSession methods (configureAudioSession, ensureAudioSessionConfigured, deactivateAudioSession) cleanly removed. start() and new restartEngine() are the only engine-lifecycle surface left.
Sources/PlayolaPlayer/Player/SpinPlayer.swift Removed all AVAudioSession calls and the isConfigured assert. New guard in handleSuccessfulDownload checks spin.id matches before engine start, preventing stale downloads from starting audio after a pause.
Tests/PlayolaPlayerTests/InterruptionTransportTests.swift New test suite covering all pauseForInterruption/resumeAfterInterruption edge cases (double-pause, idle pause, stop-clears-armed, loading pause) without touching CoreAudio. Well-scoped and clearly commented about which paths need integration tests.
Tests/PlayolaPlayerTests/SessionSeamInvariantTests.swift Structural guard test that scans Sources/ for AVAudioSession.sharedInstance, .setCategory(, and .setActive( to enforce the host-session-ownership contract. Correctly scoped to Sources/ only and handles missing-sources gracefully for CI.
PlayolaPlayerExample/PlayolaPlayerExample/PlayolaPlayerExampleApp.swift New HostAudioSession class demonstrates full host contract: configure+activate AVAudioSession, observe interruptions, and drive pauseForInterruption/resumeAfterInterruption. Observer lifecycle (token retained, removed in deinit) is correctly managed in the example.
PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift Updated to handle new .paused state in all switch sites: button color, button icon, icon offset, and the main content view. All exhaustive switches updated correctly.
Tests/PlayolaPlayerTests/PlayolaStationPlayerScheduleRetryTests.swift Minor update: StateRecorder switch adds the .paused case tag to avoid an exhaustive-switch compile error after the new state case was introduced.

Sequence Diagram

sequenceDiagram
    participant Host as Host App
    participant SDK as PlayolaStationPlayer
    participant Mixer as PlayolaMainMixer
    participant NC as NotificationCenter

    Host->>SDK: configure(authProvider:)
    Host->>Host: AVAudioSession.setCategory(.playback)
    Host->>Host: AVAudioSession.setActive(true)
    Host->>SDK: play(stationId:)
    SDK->>Mixer: start() [lazy, first play]
    NC-->>SDK: AVAudioEngineConfigurationChange
    SDK->>Mixer: restartEngine() [generation-gated]
    SDK->>SDK: play(stationId:) [if still current gen]

    Note over Host,NC: Interruption flow
    NC-->>Host: interruptionNotification(.began)
    Host->>SDK: pauseForInterruption()
    SDK->>SDK: "state = .paused(spin)"
    SDK->>SDK: "playGeneration += 1"
    NC-->>Host: interruptionNotification(.ended, .shouldResume)
    Host->>Host: AVAudioSession.setActive(true)
    Host->>SDK: resumeAfterInterruption()
    SDK->>Mixer: restartEngine()
    SDK->>SDK: "guard generation == playGeneration"
    SDK->>SDK: play(stationId: interruptedStationId)
Loading

Reviews (4): Last reviewed commit: "Merge pull request #101 from playola-rad..." | Re-trigger Greptile

Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift
Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Outdated
Greptile (PR #99) caught that the 'preserve token on failure' behavior was
defeated by play(), which resets the armed interruption fields at its very
first lines — a transient schedule-fetch failure during resume silently killed
the host's retry path.

Codex adversarial review then showed a re-arm-on-failure approach has races
(reviving a station the host stop()'d mid-resume; same-station host restart).
Rather than keep patching the re-arm, the resume is redesigned as a clean
one-shot:

- resumeAfterInterruption() consumes the armed interruption up-front and is a
  single attempt. It throws on failure (host knows — not silent); retry is via
  play(stationId:), documented. No re-arm, so no re-arm races.
- A generation-supersession guard after restartEngine() abandons the resume if
  a host stop()/play() interleaved during it (so it can't revive a stopped
  station or override a freshly-started one). Supersession during play()'s own
  awaits is handled by play()'s existing generation gate.
- stop() now clears isSuspended / wasPlayingBeforeInterruption /
  interruptedStationId ('stop means forget everything, including any armed
  interruption').

Also made handleAudioEngineConfigurationChange internal (was public) — @objc
selector target, not host API.

Tests: stop()-clears-armed-state and resume-after-stop-is-no-op (CoreAudio-free).
The armed-resume path itself calls restartEngine() (real CoreAudio) so stays
inspection-verified per the no-CoreAudio test rule.

The cross-repo .paused compile break Greptile flagged is intentional lockstep
coordination (app adopts .paused in Phase 1), not an SDK change.
@briankeane

Copy link
Copy Markdown
Collaborator Author

Re: the Greptile review here —

  • Resume retryability gap (real): fixed in fix(sdk): one-shot retryable resume; stop() clears interruption state #100 (fix → develop). resumeAfterInterruption() is redesigned as a one-shot that consumes the interruption up-front, abandons cleanly if the host stop()/play()s mid-resume (generation guard after restartEngine), and documents retry-via-play(). stop() now clears the armed interruption state. Codex adversarial review confirmed the redesign is race-free. Once fix(sdk): one-shot retryable resume; stop() clears interruption state #100 merges to develop, this PR picks it up automatically before the tag.
  • .paused cross-repo compile break (not an SDK fix): intentional. playola-radio-ios adopts .paused in its Phase 1 alongside the pin bump to 0.20.0 — the compile error is the lockstep coordination mechanism, not a regression.

Hold the 0.20.0 tag until #100 is merged into develop.

…try doc (review)

PR #100 review (Greptile P2s):
- On restartEngine() failure during resume, publish state = .error(...) (guarded
  by the supersession check) so a host driving UI from $state isn't left on a
  stale .paused with no working resume — mirrors play()'s own error path.
- Doc: clarify that the station for a retry play() is available as the player's
  stationId (still set after a failed resume; only stop() clears it), rather
  than implying the host must cache it externally.
fix(sdk): one-shot retryable resume; stop() clears interruption state
@briankeane

Copy link
Copy Markdown
Collaborator Author

@greptile review this

@briankeane

Copy link
Copy Markdown
Collaborator Author

@greptile review this — #100 (the resume-retryability + stop()-clears + .error-on-restart-failure fixes) is now merged into develop, so this release diff includes them. Please re-review.

Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Outdated
Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift
Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Outdated
PR #99 re-review (Greptile, 3 P1s): handleAudioEngineConfigurationChange's
recovery Task was missing the supersession discipline that
resumeAfterInterruption() already uses. Two issues, same fixes as resume:

- Generation gate: snapshot playGeneration (with stationToRecover) before
  restartEngine(); if a host stop()/play() bumps it during the restart, abandon
  the recovery instead of calling play(stationToRecover) — which would override
  the host's explicit station switch / revive a stopped station.
- State on failure: if restartEngine() throws, publish state = .error(...)
  (gated on generation) so a host driving UI from $state isn't left on a stale
  .playing with dead audio. Previously the failure only went to Sentry.

The recovery path itself calls restartEngine()/play() (real CoreAudio / resolves
.shared), so per the no-CoreAudio test rule it stays inspection-verified; the
synchronous guard (!isSuspended/isPlaying/stationId) is covered by the existing
engine-config tests.
… on generation

PR #101 review (Greptile P2s):
- Thread-safety: AVAudioEngineConfigurationChange fires on an unspecified
  thread and @objc dispatch bypasses @mainactor, so the handler's synchronous
  @mainactor reads (isSuspended/isPlaying/stationId/playGeneration) could race.
  Register the observer block-based with queue: .main and assumeIsolated, so the
  handler runs on the main actor; drop @objc (no longer a #selector target).
- Sentry noise: the restart-failure path now reports only when still current
  (generation == playGeneration) — a superseded recovery is moot, matching the
  state gate.

Not addressed (tracked follow-up): a PlayolaMainMixing protocol to inject a mock
mixer would unblock unit-testing the recovery Task's .error / generation-stale
branches (currently inspection-verified, like all restartEngine()/play() paths).
That's the same .shared-coupling follow-up already noted for SpinPlayer; it
touches SpinPlayer too and is out of scope for this fix.
…-generation-gate

fix(sdk): generation-gate + .error state in engine-config recovery
@briankeane

Copy link
Copy Markdown
Collaborator Author

@greptile review this — #101 (generation-gate + .error + main-queue delivery in the engine-config recovery handler) is now merged to develop, so this release diff includes it. Please re-review.

@briankeane
briankeane merged commit cb191f9 into main Jun 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant