Skip to content

Add a native CoreAudio sink and drop PortAudio from the macOS build - #66

Draft
chrisuthe wants to merge 3 commits into
mainfrom
chrisuthe/task/replace-portaudio-with-a-native-coreaudio-sink-on
Draft

chrisuthe wants to merge 3 commits into
mainfrom
chrisuthe/task/replace-portaudio-with-a-native-coreaudio-sink-on

Conversation

@chrisuthe

@chrisuthe chrisuthe commented Sep 17, 2026

Copy link
Copy Markdown
Member

The released macOS binary aborts at launch on any Mac without Homebrew's PortAudio:

dyld[38468]: Library not loaded: /opt/homebrew/opt/portaudio/lib/libportaudio.2.dylib
Referenced from: /usr/local/bin/sendspin-cli

CI brewed portaudio on the macos-14 runner and then smoke-tested the .pkg on that
same runner, so the one host that could have caught it was the one host guaranteed not to.

The fix is not to bundle the dylib but to stop needing it. CoreAudio and AudioToolbox ship
with macOS, so the binary now links only what every Mac already has. PortAudio stays in the
tree and stays ON everywhere but the macOS release leg.

The sink

src/coreaudio_sink.{h,cpp}, an AudioSink over AUHAL, modelled on portaudio_sink.{h,cpp}
and reusing PcmRingBuffer, pcm_volume and SinkRecovery/OutageGapHandoff rather than
reimplementing them. The four invariants portaudio_sink.h writes down come from the
AudioSink contract, not from PortAudio, so they carry across unchanged.

  • Sync feedback from the render callback's AudioTimeStamp, converted through
    mach_timebase_info(). That timestamp is when the HAL hands the buffer over rather than
    when it reaches the DAC, so the device's latency, safety offset, stream latency and the
    unit's converter latency go on top — unlike PortAudio, where outputBufferDacTime already
    carries them.
  • 8/16/24/32-bit through a packed signed little-endian ASBD on the unit's input scope,
    letting the AU convert. capabilities() probes the three ladders by setting each candidate
    format on a scratch unit — the same question a stream asks.
  • -o coreaudio[:<device>], with PortAudio's device grammar exactly, re-resolved per stream.
  • In-place recovery without item 14's rescan cycle: an AudioDeviceID is stable and
    AudioObjectAddPropertyListener reports kAudioDevicePropertyDeviceIsAlive directly, so
    there is no Pa_Terminate()/Pa_Initialize() and no index renumbering.
  • A moved system default is followed, deliberately outside SinkRecovery's budget: a move is
    an ordinary event, and spending the reopen on it would leave a real outage in the same
    track with nothing left.

CI

with_portaudio joins the matrix on every leg (a key missing from one reads as false),
OFF on macos-arm64 only. That leg's brew install portaudio pkgconf is gone — nothing
left on it needs pkg-config — its expect_backends is null, stdout, coreaudio, and its
runtime_packages is the fact that there are none.

And the guard that would have caught this: a macOS step running otool -L over the staged
binary and failing on any load command outside /usr/lib and /System. Worth having
whatever the backend is. Proven by hand against both a clean listing and a Homebrew-tainted
one; no deliberately broken commit is left behind.

What is and is not proven

Linux is unchanged and green: 437/439 (the two failures are a local path-length quirk of the
task worktree, not a regression — they resolve a relative socket path against a cwd that
overflows sun_path, and pass from a short cwd). scripts/smoke_test.sh,
scripts/format.sh --check and shellcheck scripts/*.sh are clean, and
tests/device_spec_test.cpp covers the -o coreaudio[:...] forms and the Linux
reserved-backend refusal on every leg.

This was written on Linux, where it is not built, so CI's macos-arm64 leg was the first
compiler to see it — and it compiles clean there under -Werror. That leg's otool -L guard
reports only CoreAudio, AudioToolbox, CoreFoundation, libc++ and libSystem: no
/opt/homebrew path, no bundled dylib. 425/425 ctest on macOS, and no brew step left.

Draft until the hardware pass, which no runner can do — every smoke_test.sh invocation
is -o null, so nothing in CI opens a device. Owed on a Mac with real output: clean tone at
48 kHz/16-bit, 44.1 kHz/24-bit and 44.1 kHz/32-bit; exact on_frames_played accounting
against the wall clock; a DAC offset plausible against the device's reported latency; a
mid-stream format change and recovery from a refused one; volume, mute, the ramp across a
change and the shutdown latch; a default-output move and an unplug while playing; and a real
Sendspin server driving it end to end. Whatever the pass finds goes into roadmap item 27 the
way item 3's did.

The released macOS binary inherited Homebrew's absolute install name for
libportaudio.2.dylib and aborted under dyld on any Mac without it. CI could
never see it: the runner brewed portaudio, then smoke-tested the .pkg on that
same runner.

CoreAudio and AudioToolbox ship with macOS, so the binary now links only what
every Mac already has. PortAudio stays in the tree, built OFF on the macOS
release leg alone.

Also add the guard that would have caught this: a macOS CI step failing on any
load command outside /usr/lib and /System.
A lost device does not stop the render callback -- the HAL keeps pulling until
the unit does. So clear() asking unit_alive_() took the ring_.drop() branch
while the callback was inside read(), writing both positions from the producer
side and leaving the next read a whole ring of stale audio to play.

clear() now asks callback_running_(), which is liveness alone. configure() and
write() keep unit_alive_(), which is the question they actually have.
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