Skip to content

PHASE A: Complete fix for Platform Audio Unity C# side - #378

Draft
MaxHeimbrock wants to merge 38 commits into
mainfrom
max/par-022-phase-a-validation
Draft

PHASE A: Complete fix for Platform Audio Unity C# side#378
MaxHeimbrock wants to merge 38 commits into
mainfrom
max/par-022-phase-a-validation

Conversation

@MaxHeimbrock

Copy link
Copy Markdown
Contributor

Background

We had several issues with platform audio on mobile (iOS & Android), especially with:

  • App backgrounding
  • Unity audio playing parallel to WebRTC audio
  • Device switching during calls (Bluetooth to speaker and back)
  • Device selection

Changes

What did you do?

  • Device selection via priority list
  • Improved session management alongside Unity audio
  • Watchdog to react to device switches

MaxHeimbrock and others added 30 commits July 30, 2026 14:14
On iOS the WebRTC ADM ran the shared AVAudioSession in automatic mode, so
joining a call rerouted other app audio to the earpiece and hanging up
deactivated the session out from under Unity (ambient audio died).

Put RTCAudioSession into manual mode and have the app own the session: hold
one permanent activation (so WebRTC's per-call setActive:NO never deactivates
it), set PlayAndRecord + VideoChat (loudspeaker by default), and gate the VPIO
unit via isAudioEnabled around connect/disconnect. Also restore the session on
PlatformAudio.Dispose (previously dead code).

Validated: Meet sample compiles for iOS (Unity 6000.3.10f1). Manual-mode audio
behavior needs on-device validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshot the app's audio session category/mode/options the first time
LiveKit configures the session, and on the last PlatformAudio dispose
(Interlocked instance counter) restore that snapshot and reactivate the
session with setActive:YES so Unity audio output resumes. Previously the
restore path hardcoded the Ambient category and left the session
deactivated, which killed Unity audio at dispose time.

Also removes the now-fixed README known issue and updates stale
VoiceChat references in doc comments (the session uses VideoChat mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backgrounding interrupts the shared AVAudioSession and WebRTC stops its
VPIO unit. On foreground RTCAudioSession restarts the unit exactly once,
with no retry, while Unity/FMOD restarts its own audio around the same
moment and can reconfigure the shared session underneath it (observed on
Unity 6; Unity 2022 happens to win the race). In manual audio mode
nothing else ever restarts the unit, leaving calls with no audio output
and no mic input.

Observe UIApplicationDidBecomeActive and interruption-ended in the
plugin and, after Unity's delayed restart has settled, re-assert
LiveKit's category/mode/options, reactivate the session, and cycle
isAudioEnabled to force a clean rebuild of the audio unit. Logs the
session state before the re-assert so device tests can see who won the
focus race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device-verified: with the foreground recovery in place, platform audio
survives background/foreground on Unity 6, and the change is backwards
compatible with Unity 2022.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Define the phase-A routing API on PlatformAudio, purely in C# and shaped
identically to the planned FFI-backed implementation so app code written
against it survives the plumbing swap:

- AudioOutputKind enum (values mirror the planned FFI proto enum 1:1)
- AudioDevice.Kind / AudioDevice.IsSelected
- OutputPreference ranked policy (default BT > wired > speaker > earpiece)
- IsSpeakerOutputPreferred as documented sugar over the list order
- SelectOutput / ClearOutputOverride sticky override
- DevicesChanged event (playout, recording) on the Unity main thread
- internal IRouteController seam with desktop (FFI enumeration/GUID
  selection) and unsupported-mobile implementations; Android/iOS
  backends plug into the seam in follow-up work

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement the PAR-019 routing seam on Android as an SDK-owned backend,
hardened from the device-validated sample hotfix (PR #364):

- AndroidRouteController (API 31+) pins the communication device to the
  sticky SelectOutput override while its device is available, else the
  highest-ranked available kind per OutputPreference; kinds missing from
  the ranking are never auto-selected (pin released, OS default applies)
- session-scoped MODE_IN_COMMUNICATION with save/restore of the prior
  mode; optional audio-focus request (internal, default off)
- change detection via OnCommunicationDeviceChangedListener plus a 1.5 s
  poll thread for the trace-verified transitions that fire no OS event
  (device added while pinned; BT headset leaving the available list ~10 s
  after the route already fell back); no-op re-pin guard stops the
  feedback loop from our own setCommunicationDevice
- GetDevices playout list and DevicesChanged now report real Android
  communication devices with Kind and IsSelected; recording stays the
  FFI default-input placeholder
- StartRecording re-asserts the policy: since Android 13 the mode
  request is only honored while voice-communication capture is active
- pre-API-31 stays a documented unsupported placeholder, matching the
  hotfix gate; platform notes on the public API updated accordingly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… into max/par-021-unity-ios-session-features
The audio session config is now derived from a small state machine
(idle / playout-only / recording) driven from C#, where PlatformAudio
knows the recording state, plus a speaker-vs-earpiece preference
expressed through the session mode (VideoChat/VoiceChat) — never
overrideOutputAudioPort, so external devices always win. Non-recording
states drop to mode Default + MixWithOthers to keep Unity audio
unprocessed between calls; PlayAndRecord stays because the fork's ADM
supports playout-only via an input-disabled VPIO unit but nothing
guarantees VPIO under the Playback category. Every apply is mirrored
into WebRTC's RTCAudioSessionConfiguration snapshot (reflected, no
link-time dependency) so ADM-driven restarts re-apply the same config.

Route changes are observed via AVAudioSessionRouteChangeNotification
and forwarded to the new IosRouteController, which implements the
PAR-019 seam: the playout list is the session's current output route
with real Kind/IsSelected, DevicesChanged is raised on route changes
(marshalled to the Unity main thread by PlatformAudio), OutputPreference
reduces to the Speaker/Earpiece relative order, and SelectOutput throws
the documented NotSupportedException pointing at AVRoutePickerView.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ADM skips the VPIO rebuild on route changes that keep the hardware
sample rate (HandleValidRouteChange -> HandleSampleRateChange no-ops),
so a live VoiceChat -> VideoChat switch left the unit calibrated for
the receiver — device-observed as an attenuated loudspeaker after an
earpiece -> speaker toggle. Cycle isAudioEnabled after a mode change
(when call audio is wanted) to force a clean rebuild against the new
route, the same mechanism the foreground recovery already uses. The
recovery and configure paths pass NO: the former cycles itself, the
latter runs before the unit exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…11-samples-migration

# Conflicts:
#	Runtime/Scripts/Audio/PlatformAudio.cs
#	Runtime/Scripts/Audio/RouteController.cs
…ration

# Conflicts:
#	Samples~/Meet/Assets/Runtime/MeetManager.cs
…011)

With PAR-019/020/021 the SDK owns output routing (OutputPreference policy,
sticky SelectOutput, DevicesChanged, communication-mode ownership on Android),
so the sample-level reimplementation is deleted: RouteRank, the AudioManager
JNI plumbing (setMode/setCommunicationDevice, the communication-device change
listener) and the route watchdog poll, plus their call sites in MeetManager
and LiveKitAgentSession.

The controller now only demonstrates the API: it relies on the default
OutputPreference ranking (customizing it is shown as a one-liner) and logs
DevicesChanged with device kind and selection state. The Android capture
lifecycle policy stays: capture starts at call begin and keeps running while
muted, since Android 13 only honors the communication-mode request — and with
it the SDK's route pin — while the app has active voice-communication capture.

Both PlatformAudioController copies (Meet + Agents) stay byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an "Audio Output Routing" section to the platform-audio docs: the
default OutputPreference order, the IsSpeakerOutputPreferred convenience
toggle, sticky SelectOutput/ClearOutputOverride, DevicesChanged, and
per-platform behavior (Android 12+/older Android/iOS/desktop), including
the MODIFY_AUDIO_SETTINGS requirement and the Android 13 active-capture
constraint. The Meet sample README points at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Android routing backend took MODE_IN_COMMUNICATION and pinned the output
route in its constructor and held both until Dispose, so an app that creates
PlatformAudio at startup to keep one ADM alive sat in call mode from launch to
quit. SetSessionAudioEnabled — the switch iOS already uses for this — was a
documented no-op on Android.

It is now plumbed through the routing backends: the Android backend takes the
session on enable and hands it back on disable (pin cleared, replaced mode
restored per transition, never an unconditional MODE_NORMAL), while enumeration,
the change listener and the poll thread stay alive in both states so GetDevices
and DevicesChanged keep working while idle. Every re-evaluation path — listener,
poll, and the StartRecording re-assert — is observation-only while disabled, so
none of them can resurrect a released session. The documented default (enabled
at creation, uniform with iOS) is unchanged; the samples disable it right after
creating PlatformAudio and MeetManager re-enables it for the duration of a call.

The samples also recover Unity's own audio across output route changes: it does
not follow a route change on its own, so the controller reopens the audio engine
with AudioSettings.Reset, driven by Unity's OnAudioConfigurationChanged and by
the SDK's DevicesChanged (logging both, since which one Android delivers is
device behavior), coalesced so one route change resets once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device testing contradicted them: Unity audio kept playing over a Bluetooth
headset both while idle and alongside an active call, so the docs must not
assert that holding the call session forces a headset onto a call link instead
of A2DP media. What the SDK actually does — request communication mode, pin the
route, and release both when session audio is disabled — is stated instead; how
a given device carries call and media audio concurrently is left to the device
observation the card still owes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eoff

Device testing (Pixel 8a / Android 16, classic BR/EDR headset) established what
actually breaks Unity's audio engine: a device being added or removed, not the
route moving between the devices already connected. Joining a call leaves game
audio playing, including when the platform moves media from the headset's A2DP
link onto its call link. Resetting on every route change would therefore have
restarted the game's audio at each join and hang-up for nothing, so the sample
now triggers on the device set and resumes each source at the position it
reached instead of from the start of the clip.

The same run answered what the platform does with media during a call on a
classic Bluetooth headset: dumpsys shows STREAM_MUSIC on bt_sco_hs while the
call is active and back on bt_a2dp afterwards, with A2DP suspended for the
duration — media is carried by the call link, not diverted or dropped. The
README documents that as the observed behavior on that device, and as a further
reason to hold the session only while a call is in progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Device testing (Pixel 8a / Android 16): disconnecting a Bluetooth headset does
raise AudioSettings.OnAudioConfigurationChanged, but with deviceWasChanged=false,
so the flag cannot separate a device change from any other reconfiguration and
the recovery never ran. The sample now reacts to the callback either way; the
callback AudioSettings.Reset raises itself always lands inside the coalescing
window, so the recovery still cannot feed itself.

This also makes Unity's callback the fast path rather than the SDK's event: a
powered-off headset can stay in the platform's device list for seconds after the
route moved, so the device-set signal trails the disconnect. The reset now
reports how many sources came back and on which output, so a run can tell a
failed reopen from a successful one that stayed silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adb traces of a Bluetooth connect on a Pixel 8a (Android 16) show why the
recovery left the app silent: Unity stops every AudioSource when it reinitializes
its engine and raises OnAudioConfigurationChanged 25 ms afterwards, so the
handler's snapshot of "what is playing" was always empty and it restored nothing.

    34.920 AudioTrack stop(11092): called with 92104 frames delivered
    34.945 Unity audio configuration changed (deviceWasChanged=False, ...)
    34.947 Reopening Unity's audio output (...), resuming 0 source(s)

The same trace shows the two events are ordered the other way round than assumed:
a headset's call profile appears ~650 ms before its media profile takes over
(32.912 SCO available, 33.576 setA2dpActiveDevice), so the SDK's DevicesChanged
arrives while the engine is still healthy and Unity's callback arrives after the
damage. They are now used accordingly — DevicesChanged remembers what is audible
and touches nothing, Unity's callback puts it back — instead of both racing to
reopen the engine, which is what made the outcome depend on timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adb traces of a mid-call Bluetooth connect on a Pixel 8a (Android 16) show
AudioSettings.Reset is not merely unnecessary but actively harmful. Reinitializing
the engine makes Unity claim the headset's call link through the deprecated
AudioManager.startBluetoothSco(), which evicts the setCommunicationDevice pin the
routing backend holds and leaves the platform unable to bring SCO up again:

    03.281 setCommunicationRouteForClient … bt_sco_hs addr:…E0:03  (setCommunicationDevice, ours)
    03.928 [AudioSettings.Reset]
    04.060 setCommunicationRouteForClient … null                   (stopBluetoothSco, Unity)
    04.083 setCommunicationRouteForClient … bt_sco addr:           (startBluetoothSco, Unity)
    04.783 AS.BtHelper: requestScoState: failed to connect in state 1
    06.295 … and on every 1.5 s re-pin thereafter

Call audio and game audio both stayed on the loudspeaker for the rest of the
session as a result. Unity has already reopened its output by the time it raises
OnAudioConfigurationChanged, so the recovery only has to restart the app's own
sources — which it now does, idempotently, leaving anything Unity kept running
alone. The README's advice to reset is replaced with the reason not to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a Bluetooth device is not synchronous: setCommunicationDevice starts an
SCO negotiation and the platform keeps reporting the previous communication device
until it completes. The 1.5 s poll took that for a dropped pin and re-issued the
request into its own pending activation, which the platform refuses — and the
refusal aborts the activation, so the route never arrived at all. Every failure in
the trace lands on the poll's cadence, with nothing else in the process asking for
SCO:

    10:41:38.685 setCommunicationDevice() -> updateCommunicationRoute,
                 preferredCommunicationDevice: null
    10:41:40.193 … 41.705 … 43.216 … (every 1.5 s for the whole call)
    10:41:50.764 AS.BtHelper: requestScoState: failed to connect in state 1
                 AS.AudioDeviceBroker: failure to start BT SCO for uid: 10424

Call audio and the app's own media both stayed on the loudspeaker for the duration,
and both returned to the headset on hang-up, when the pin was cleared.

An outstanding pin now gets PinSettleTimeout to take effect before being issued
again. Recovering from a pin the platform drops silently — what the poll exists for
since PAR-020 — is unaffected: once the pin has been seen honored, any later
divergence re-pins immediately, and real route changes arrive through the change
listener rather than the poll.

This only became reachable with the session gating: while the route was pinned at
construction, it had long since been applied before any call, so the poll never
re-issued it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Withdrawing the outstanding legacy SCO request from here does not work. Measured on
device: stopBluetoothSco ran 158 ms before the pin and the pin was refused all the
same ("requestScoState: failed to connect in state 1"), because the request belongs
to a different client in the process and cannot be cancelled by this one.

So this is a situation the SDK can report but not repair. A pin the platform takes
without acting on is now retried with a doubling backoff up to 30 s instead of every
6 s, and the first failure logs a warning naming the likely cause and the
consequence, so it is diagnosable without LK_VERBOSE. The README documents it as a
known limitation with the workaround that does work — connect the headset after the
app has started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A trace containing both outcomes inside one call settled it. Joining with the
headset connected since before launch left the pin unapplied; the headset then
dropped, the platform ran resetBluetoothSco, and on reconnect the same pin was
applied 708 ms later:

    13:47:32.690 setCommunicationDevice(bt_sco_hs) -> preferredCommunicationDevice: null
    13:47:40.227 updateCommunicationRoute … eventSource: resetBluetoothSco
    13:47:49.828 setCommunicationDevice(bt_sco_hs) -> null
    13:47:50.536 … preferredCommunicationDevice: bt_sco_hs
                 eventSource: BtHelper.onScoAudioStateChanged, state: 12

So the limitation is a platform SCO state that can be left pending, not Unity's
grab specifically — the grab provokes it but the previous wording made it the
whole cause, and the same failure occurs on Unity 6, which does not grab. The
note now says what the state does, what clears it (a headset reconnect, a
Bluetooth toggle, a reboot), and that routing is prompt once it is clear.

This also refutes the A2DP-streaming theory the card carried: the failing join
happened with the app's own audio stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MaxHeimbrock and others added 8 commits August 25, 2026 16:59
The Agents sample never re-enabled session audio. The shared controller's
Initialize() hands the session back, and only MeetManager took it again on
join — LiveKitAgentSession did not, so every agent call ran with session
audio off: no VPIO unit on iOS (no mic, no agent audio at all), no
communication mode and no route pin on Android 12+. The session is now
enabled after the room connects and disabled in EndSession, and the
controller comment no longer claims MeetManager is the only caller.

A server-initiated disconnect left the microphone recording. OnDisconnected
only disabled session audio; Unpublish/StopCapture ran solely from
OnEndCall/OnDestroy. The capture is deliberately kept open across mute
cycles, so after a kick, a deleted room, or a token expiry the mic stayed
hot — indicator on — until the user pressed End Call. Both paths now share
one idempotent TeardownCall.

The pin settle/backoff state was only maintained on the success path.
_pinnedAtUtc was stamped only when setCommunicationDevice returned true, so
one refused re-issue froze the anchor; once the elapsed time passed the 30 s
cap, "settling" was false on every pass and the backoff decayed into the
warn+re-issue every 1.5 s poll tick it exists to prevent. The stamp now
lands on every attempt, both release sites reset the tracking through one
ResetPinTracking(), and the window is measured with the monotonic Stopwatch
clock instead of DateTime.UtcNow, so a wall-clock step can no longer stretch
or cut it.

Compile-checked 8/8: package + Assembly-CSharp for Meet (editor, iOS player)
and Agents (editor, Android player); sample controller copies byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- SetSessionAudioEnabled (Android backend) promised the sticky override
  survives a disable/enable transition unconditionally. It does not: the
  drop-on-disappear bookkeeping keeps running while the session is disabled,
  so a device that leaves the list between calls clears the override for
  good. The doc now says so instead of promising otherwise.
- SelectOutput never stated its session-enabled precondition on Android:
  while session audio is disabled the choice is only recorded, no pin is
  issued, and the device lists keep reporting the platform's route. Added to
  the XML doc and the README, which endorses pre-call device pickers.
- StartRecording claimed recording starts automatically when PlatformAudio
  is created. The Rust side only acquires the ADM in PlatformAudio::new();
  capture starts solely through start_recording.
- The sample recovery comment told readers an "echo window" swallows the
  callback its own reset raises — both were removed on this branch; the real
  re-entry guard is idempotence (a playing source is left alone).

Compile-checked 8/8; sample controller copies byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two route-reporting/recovery changes in AndroidRouteController:

- The pin settle window now applies only to Bluetooth targets. Its whole
  justification is the asynchronous SCO negotiation; the other kinds apply
  without one, so a divergence there is always a dropped or ignored pin and
  is re-issued immediately — restoring the pre-gate recovery speed for
  speaker/wired/USB. A pending Bluetooth pin is deliberately still not
  re-issued by the StartRecording re-assert: re-issuing lands in our own
  pending activation and aborts it (the F11 livelock), so the up-to-6 s wait
  in the join flow is the safer side of that trade.
- Reevaluate now reports the device the platform actually has, never the one
  merely requested: the selected id is re-read after each issue and left at
  the current device while a pin settles. DevicesChanged and GetDevices()
  agree again, a stuck pin no longer shows the target as selected for the
  whole call, and the real arrival raises its own DevicesChanged (the
  optimistic report used to swallow it, since the signature never changed).

And the sample recovery (both controller copies, byte-identical) fixes the
holes the review verified:

- Restore runs BEFORE remember in OnUnityAudioConfigurationChanged. The
  engine has already stopped every source at that point, so remembering
  first purged every interrupted one-shot and the restore put back nothing
  but loops.
- The healthy-signal remember pass (OnDevicesChanged) now forgets any
  stopped source, loops included: it runs before the engine reinit, so a
  stopped source there was stopped by the app, and a deliberate Stop() must
  not be undone by the next device change. The post-restore pass only
  refreshes positions, so a Play() the engine rejected mid-burst keeps its
  slot for the next callback.
- The adopt-all-loops fallback is gated to the very first switch (before any
  sweep has run); afterwards it would force-play loops the app stopped or
  never started.
- Sources on deactivated GameObjects are dropped instead of being retried
  with a warning on every callback and force-played on reactivation.

Compile-checked 8/8; sample controller copies byte-identical. Needs one
device pass: BT join flow (settle + arrival event) and the Unity-audio
recovery scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StartRecording gated on the permission dialog on Android but not on iOS, so
an app that starts its capture automatically — the Agents sample does, right
after connecting — opened the ADM's input unit while the first-run permission
prompt was still pending. A capture opened before the grant records silence,
and nothing reopens the input after the user taps Allow, so the whole session
published a silent microphone track: the agent never heard the user. Meet
never shows this because the capture starts from the unmute button, on
installs whose permission was granted long ago.

The coroutine now mirrors the Android gate: request the authorization, yield
until the user answers, throw on denial, and only then open the capture.
Already-granted installs pass straight through.

Inferred from the code and platform behavior; needs the device pass on a
fresh install (prompt path) plus a second run (granted path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LeaveCommunicationMode swallows exceptions, and its caller flips
_sessionAudioEnabled before it runs — so one transient JNI failure during a
hang-up (e.g. the activity briefly unavailable during pause) left the
platform in MODE_IN_COMMUNICATION with the route still pinned, while the
controller reported the session released. A repeat disable early-returns on
the unchanged flag, and the disabled-mode Reevaluate never issues a clear,
so nothing repaired it until the next call or Dispose. The enable side had
the same hole in the opposite direction.

A failed transition now marks itself pending, and Reevaluate — where every
trigger already funnels: the poll, the change listener, the StartRecording
re-assert — retries the transition for the current desired state, so a flip
that happened in between is never undone. Both methods are idempotent when
re-run partially completed (mode save/restore already paired per transition;
clearing an unheld pin is a no-op). The failure warns once; retries are
silent and the next success clears the flag.

Compile-checked 8/8. No dedicated device scenario: the path only differs
when the platform throws mid-transition; a normal join/leave pass covers the
regression risk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndroidRouteController's constructor entered MODE_IN_COMMUNICATION and
pinned the route because session audio defaults to enabled, and the
recommended pattern of disabling right after construction then produced
a take -> pin -> clear flap at every app launch (a measured 320 ms
transient; with a Bluetooth headset connected, an SCO activation
started only to be cleared mid-negotiation, inside the same startup
window where the platform's SCO state machine is fragile).

The enabled default and the PAR-019 API surface are unchanged. The
constructor now only records the state; the session is first acquired
by the first trigger that needs it while enabled: an explicit
SetSessionAudioEnabled(true) call (also when the state was already
enabled - the way in for receive-only apps), ApplyOutputPreference
(which the StartRecording re-assert funnels through, where Android 13
starts honoring the mode request anyway) or SelectOutput. Disabling or
disposing before anything acquired the session releases nothing, so
the create -> disable startup path issues no audio-mode traffic at all.

The mode save now happens at the actual acquisition and the
save/restore idempotency rules are untouched; enumeration, the change
listener and the poll thread still start in the constructor, and
neither the poll nor the listener can take a lazily-deferred session.

Behavior change, deliberate: an app that plays remote audio, never
records, never touches routing and never calls SetSessionAudioEnabled
no longer gets the mode and pin from construction; it opts in with
SetSessionAudioEnabled(true) at its call boundary, per the documented
"enabled == a call is in progress" contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every public member now throws ObjectDisposedException after Dispose()
instead of reaching a disposed FfiHandle (or, on iOS, driving the audio
session after it was handed back). DevicesChanged subscribe/unsubscribe
and double-Dispose stay safe, as before.

The constructor no longer leaks the FFI handle when route-controller
creation fails after the handle exists: the post-handle work is wrapped
so the handle (and a half-built controller) are disposed before the
exception propagates, instead of waiting on the SafeHandle finalizer.

The SelectOutput deferral while session audio is disabled deliberately
gets no pending flag (PAR-019 keeps the surface frozen); the XML doc and
the README routing section now spell out how a pre-call picker tracks
its deferred choice through the existing IsSelected/DevicesChanged
surface, including the drop-on-disappear case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-022)

The last missing entry from PAR-022's desktop-runnable test list: a fresh
PlatformAudio created after a full dispose in the same session must come
up working (the native ADM ref-count has to return to zero cleanly), and
the first instance's preference mutations must not leak into it. The
other listed tests (preference roundtrip/precedence, bogus SelectOutput,
event safety across dispose) already exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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