Skip to content

feat: add a per-app sound mixer switch - #213

Open
lou1s19 wants to merge 11 commits into
jacklandrin:mainfrom
lou1s19:feat/sound-mixer
Open

feat: add a per-app sound mixer switch#213
lou1s19 wants to merge 11 commits into
jacklandrin:mainfrom
lou1s19:feat/sound-mixer

Conversation

@lou1s19

@lou1s19 lou1s19 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds a per-app sound mixer to OnlySwitch: a new switch that shows a compact
mixer in the switch list popover, modelled on the macOS system Sound panel.

  • System volume slider
  • A per-app row for every app currently playing audio, with mute and a
    volume slider
  • The current output device

How it works

Two mechanisms, chosen automatically per app:

  1. Scriptable media apps (Music, Spotify, TV, iTunes) via AppleScript's standard
    sound volume (0 to 100).
  2. Any other app that outputs audio, including browsers like Chrome, via
    a Core Audio process tap (AudioHardwareCreateProcessTap, macOS 14.4+).
    macOS exposes no per-app output-level API, so the tap routes the app's audio
    through a private aggregate device and scales the samples itself (continuous
    gain, of which mute is the 0 case). It falls back to silencing on any
    non-Float32 stream format. Below macOS 14.4 only the scriptable apps appear.

Where it lives

SwitchType.soundMixer plus a SoundMixerSwitch, so it is enabled in
Settings > Customize like every other switch. No bespoke preference and no
settings page of its own. While it is on, SoundMixerPanelView sits at the top
of the switch list and expands in place, the same shape as
AuthenticatorPanelView. No extra menu bar item.

Notes

  • Adds NSAudioCaptureUsageDescription, because the process tap triggers the
    system audio capture permission prompt on first use.
  • All new UI strings are added to Localizable.xcstrings in the full set of
    languages the project already ships.
  • Tested on Apple Silicon (macOS 26.6): the switch in Customize, the panel in
    the popover, system volume, Spotify via AppleScript and Chrome via the
    process tap, including mute and repeated slider drags.
  • One open question about the tap's shared state is in the comments below.

Add an "App Sound" settings section and an optional menu bar item that
opens a mixer styled after the system Sound panel: a system-volume
slider, a per-app row for every app currently playing audio, and the
current output device.

Two control paths cover different apps:
- Scriptable media apps (Music, Spotify, TV, iTunes) via AppleScript's
  `sound volume`.
- Any other app that outputs audio, including browsers, via a Core Audio
  process tap (macOS 14.4+) that scales the app's samples through a
  private aggregate device.

The menu bar item is opt-in and is created before the hide-menubar mark
item so it survives collapsing. Adds NSAudioCaptureUsageDescription for
the process-tap capture prompt.
@jacklandrin

Copy link
Copy Markdown
Owner

Sorry, I updated something recently. Can you please resolve the conflicts?


/// Settings page for the mixer: the menu bar switch plus a live preview of the panel that the
/// menu bar item shows.
struct SoundMixerSettingView: View {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think you need a new type of switch(SwitchType.swift) instead of a setting to toggle it.
Besides, we should avoid adding new icon on menu bar. So the popover content would be better to be included in the settings. Wdyt?

lou1s19 added 10 commits August 8, 2026 11:48
Resolves the conflicts jacklandrin asked about:

- project.pbxproj: both sides appended to the same Sources build phase,
  kept both sets of files.
- Localizable.xcstrings: no key was touched by both sides, so the 17 new
  mixer keys were inserted into upstream's file without reformatting it.

Build verified with Xcode 26.6 on macOS 26.6.
Addresses the review on jacklandrin#213.

- Adds `SwitchType.soundMixer` and `SoundMixerSwitch`, so the mixer is
  enabled in Settings > Customize like every other switch. The bespoke
  `Preferences.soundMixerMenubarItem` and the "App Sound" settings page
  are gone.
- Drops the second status item. The panel now lives in the switch list
  popover and expands in place, modelled on `AuthenticatorPanelView`.
  That also removes the ~80 lines in `StatusBarController` that only
  existed to keep a second item clear of the hide-menu-bar mark.
- Moves the files out of `Features/Settings/` into `Features/SoundMixer/`
  and renames the two that were named after the settings page.
- Removes the twelve now-unused strings, adds "1 app"/"%lld apps".

The Core Audio process tap is unchanged.
From the review pass:

- refresh() now returns early unless the mixer is on. The workspace
  observers fire regardless of what is enabled in Customize, and it is
  enumerating audio processes that triggers the audio-capture prompt.
- Turning the mixer off releases every process tap, so an app cannot
  stay stuck at a reduced gain with no interface left to raise it.
- The panel header is a Button again, matching AuthenticatorPanelView,
  so VoiceOver and the keyboard reach it.
Crash found while testing: EXC_BAD_ACCESS in swift_release_dealloc on
the main thread, with a garbage pointer. That is heap corruption, and
the tap's realtime callback is the only place in the app that manages
raw memory.

- teardown() freed the TapState the IOProc block dereferences on every
  buffer. AudioDeviceStop returns before the IO thread is guaranteed to
  be finished, so a late callback wrote into released memory. The state
  is now marked retired and kept for the life of the process (16 bytes
  per tap rebuild), and the callback returns immediately once retired.
- releaseAllTaps() mutated muteTaps while iterating its keys.
- refresh() reassigned rows every two seconds even when nothing changed,
  which relaid out the popover under the pointer on every tick.
While a slider is dragged, setVolume was creating and destroying a
private aggregate device on every movement: crossing full volume tore
the tap down, and any change to the app's process set rebuilt it. Each
of those is a synchronous Core Audio setup on the main thread, which is
what made the UI hang before it crashed.

The live path now only writes the tap's target gain, so an app costs one
Core Audio setup instead of one per mouse event. Tearing down at full
volume and rebuilding after a process change happen when the user lets
go of the slider.
Every AppleScript round-trip in refresh() suspends, and a browser spawns
and kills a helper process per tab, so the workspace observers fire it
again and again. Several refreshes then interleaved at those suspension
points, each enumerating audio processes and tearing down taps for apps
the other one had just recorded.
Guard Malloc pinned the crash: setVolume faulted reading a freed page
while building a Set from a row's process ids. The array's storage had
already been released.

The panel drove the sliders with ForEach($vm.rows) and wrote the new
volume back through the element binding from inside the drag handler,
so SwiftUI's access to the array overlapped the view model's own
mutation of it. The rows iterate by value now, MixerSlider keeps the
in-flight value in its own state, and every change reaches the view
model as a call with the row id instead of a write into its storage.
AppTap documents itself as plain data because it is copied around a
nonisolated(unsafe) dictionary and handed to nonisolated teardown code,
but it held a [AudioObjectID]. Every crash so far ended in a release of
that array's buffer, most recently while tearing a tap down.

The set of processes a tap covers is only ever compared for equality, so
the struct now carries an order-independent fingerprint instead and has
no reference-counted members at all. CATapDescription also gets an array
built for it rather than one the caller still holds.
… a11y

From the second review pass:

- The gain ramp carried its cursor from one AudioBuffer into the next.
  With non-interleaved stereo that put the second channel at the target
  value from its first sample, audible as the image jumping sides while
  a slider moves. Each buffer is one channel of the same frames, so all
  of them now ramp from the same starting gain.
- Retired tap states were kept for the life of the process. They are now
  freed once 32 further taps have been torn down, which still puts many
  device teardowns between the last possible callback and the free.
- The custom slider is reachable by VoiceOver and the keyboard, and the
  mute button has a label.
Freeing them after a grace period traded a 16-byte-per-teardown leak for
the exact failure that was so hard to find, and a tap is built once per
app rather than per interaction, so the leak is a handful of allocations
per session. Not worth the risk.
@lou1s19 lou1s19 changed the title feat: add per-app sound mixer with menu bar access feat: add a per-app sound mixer switch Aug 8, 2026
@lou1s19

lou1s19 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! Conflicts are sorted, merged instead of rebased so your comment stays put.

Both points are in: the mixer is a SwitchType now (soundMixer plus a
SoundMixerSwitch, enabled in Settings > Customize), and there is no extra menu bar
icon. The panel sits in the switch list popover and expands in place, same idea as
AuthenticatorPanelView. You said settings window, I went with the popover since a
volume slider felt wrong three clicks deep. Happy to move it if you prefer.

Testing also turned up heap corruption in the process tap that was in there since my
first commit. Six fixes, one commit each.

One question: TapState is shared between the main actor and the Core Audio IO
thread without atomics. Works on arm64, still a data race on paper. Fixing it needs
either Synchronization.Atomic (macOS 15, while the tap is 14.4) or a small C shim.
Which would you prefer? And happy to split the process tap into its own PR if that
is easier to review.

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.

2 participants