Skip to content

Audio Switcher: save and apply input/output device profiles - #29

Open
havokentity wants to merge 2 commits into
mainfrom
feat/audioswitcher-profiles
Open

Audio Switcher: save and apply input/output device profiles#29
havokentity wants to merge 2 commits into
mainfrom
feat/audioswitcher-profiles

Conversation

@havokentity

Copy link
Copy Markdown
Owner

Wave 2 Phase A.

Implementation: Audio Switcher: save and apply input/output device profiles
Review fixes: Audio Switcher: make applying a profile actually all-or-nothing

Built by an isolated agent, then adversarially reviewed by an independent agent that checked the branch out and ran swift build + the full suite itself. Every review finding was fixed, with a regression test added for each high and medium.

🤖 Generated with Claude Code

havokentity and others added 2 commits July 21, 2026 13:00
Switching both halves of an audio setup — headset in and out, then back to
speakers and the desk mic — is four clicks through two lists every time. A
profile captures the pair under a name and replays it in one.

Profiles pin devices by their CoreAudio UID, never by AudioDeviceID. The
numeric id is assigned as the HAL enumerates hardware and is recycled across
reboots and re-plugs, so a profile stored by id would eventually address
whatever unrelated device inherited the number — and quietly switching the
user's audio to the wrong device is the one failure this feature must never
produce. Names are stored alongside the UID purely so a disconnected device can
still be named in the UI; resolution never matches on them, because two
identical USB headsets report the same name and a name fallback would pick
whichever came first.

Resolution is therefore richer than a bool. Each direction resolves to
unpinned, resolved, missing, or wrong-direction: a device can be attached under
the pinned UID and still have stopped serving that direction (an aggregate that
lost a sub-device, an interface switched to a capture-only mode), and making it
the default output would silently kill playback. Applying is all-or-nothing —
a half-applied "Meeting" that moved playback but left the mic on the laptop is
worse than one that refuses and says which device is missing, so the row is
disabled and carries the reason instead.

Capture refuses a leg whose device exposes no UID rather than pinning to an
empty string, which would later match any other UID-less device, and refuses
the whole profile when neither leg survives that check — a row that can never
resolve is worse than an explanatory refusal at save time.

The model, its resolution, and the UserDefaults store are pure and nonisolated,
so the crux cases are tested headlessly: id reassignment across a reboot,
two devices sharing a name, a missing device, a device that no longer serves
its direction, an empty stored UID, the empty list, and round-trip persistence.

The controller now publishes one device list carrying UIDs (enumerated through
AudioSwitcherKit, which reads them) with the per-direction lists derived from
it, so the two cannot drift; the assignment is guarded on an actual change for
the same reason refreshVolumeAndMute's writes are.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
applyProfile threw away both setDefault return values and returned true no
matter what, so the one failure the feature is built to prevent was also the
one it could not report. A profile pinning the Bose for output and the MV7 for
input resolves cleanly, the output write lands, and the input write is then
refused — because the mic was unplugged in the window after resolution, or
because CoreAudio simply rejects that device as a default, which some
aggregates and virtual drivers do while still reporting streams. Playback moved,
capture did not, the call said "applied", and the popover said nothing at all.
In the all-writes-refused variant the row looked applicable forever and clicking
it was a silent no-op.

Applying now goes through AudioProfileApplier, which reads both results and puts
back whichever half landed when the other is refused, so the user ends up where
they started rather than on a half-switched setup they never asked for and never
saw happen. It returns an outcome rather than a bool: the popover has to say
which device the HAL rejected, and it has to distinguish a rollback that worked
("nothing was changed") from one that was refused too, where the defaults really
are mixed and pointing the user at System Settings is the honest answer. The two
writes are injected, so the refusal and rollback paths — which no CI machine can
produce on demand — are tested headlessly.

The name field also came up unfocused. Both entry points set the @focusstate in
the same synchronous update that inserted the TextField, and SwiftUI evaluates
.focused(_:equals:) at insertion, so the request named a view that did not exist
yet and was dropped: the user clicked +, saw a pre-filled box, and typed into
nothing until they clicked it, with Return not committing either. The field now
asks for focus itself once it has appeared, a runloop turn later.

Co-Authored-By: Claude Opus 4.8 <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