Conversation
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.
The render timestamp is when the hardware consumes the buffer, so the presentation latency belongs on top of it -- but the safety offset does not. That is the margin the HAL schedules ahead by, so it is already in how far in the future the timestamp sits, and adding it biased every DAC time by the device's offset, typically 33-64 frames. Leaves kAudioDevicePropertyLatency + kAudioStreamPropertyLatency + the unit's converter latency, which is what cubeb adds to the same AUHAL timestamp.
The sink plays: clean tone at all three formats, no accumulating drift, a DAC offset that holds across two devices, recovery from both a default move and an unplug, and a second player in phase over a real server. Two things came back open. The volume ramp was heard as a step on full->half, which is a 12.9 ms ramp rather than the 20 ms a full-scale change takes -- so open_unit_() now logs the step, since a fast ramp and a snap sound alike but only one of them is a bug. The outage-gap figure is still owed: the test harness lost frames of its own and swamped it.
A dying device reports itself through kAudioDevicePropertyDeviceIsAlive, but a returning one has nothing to report, so a replugged named device was found only by SinkRecovery's 2->30 s ladder. The hardware pass measured the cost: a device physically back at 21 s was not reopened until 41 s, because the ladder had already doubled past it. Listen on kAudioHardwarePropertyDevices too, and give SinkRecovery a rescan_soon() that brings an owed rescan forward to the next tick. It arms nothing that is not already owed and the attempt still counts, so a burst of plug events cannot spin.
The device-list listener is confirmed on hardware at 190 ms from replug to reopen, where the backoff ladder would have waited another 11.5 s. The pass also turned up a second outage in one stream never recovering. That one predates this backend and sits in shared SinkRecovery, so it gets item 28 and a task of its own rather than widening this branch.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Listener lifetime and ring-tail races can cause use-after-free behavior and incorrect playback accounting.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (5)
What changed in this PR
Adds a native CoreAudio backend so macOS releases no longer depend on Homebrew’s PortAudio library.
Changes:
- Implements CoreAudio playback, device selection, latency reporting, and recovery.
- Integrates CoreAudio into CLI parsing, CMake, tests, and documentation.
- Disables PortAudio for macOS releases and adds a system-library dependency check.
| File | Description |
|---|---|
src/coreaudio_sink.h |
Defines the CoreAudio sink interface and state. |
src/coreaudio_sink.cpp |
Implements AUHAL playback, probing, recovery, and synchronization. |
src/audio_sink.h |
Adds the CoreAudio backend identifier. |
src/audio_sink.cpp |
Registers, constructs, and lists CoreAudio devices. |
src/cli.h |
Makes CoreAudio the macOS default output. |
src/sink_recovery.h |
Adds immediate rescan support. |
src/sink_recovery.cpp |
Implements notification-triggered rescans. |
tests/device_spec_test.cpp |
Tests CoreAudio output syntax and availability. |
tests/sink_recovery_test.cpp |
Tests immediate rescan behavior and limits. |
CMakeLists.txt |
Builds and links CoreAudio on Apple platforms. |
.github/workflows/build.yml |
Removes macOS PortAudio and checks dynamic dependencies. |
.github/workflows/build-armv6.yml |
Explicitly retains PortAudio for ARMv6. |
docs/wiki/Advanced-Usage.md |
Documents CoreAudio device syntax. |
docs/wiki/Configuration.md |
Documents the new default and examples. |
docs/wiki/Home.md |
Lists CoreAudio among supported backends. |
docs/wiki/Installation.md |
Clarifies dependency-free macOS installation. |
docs/wiki/Troubleshooting.md |
Includes CoreAudio buffering behavior. |
docs/ROADMAP.md |
Records the CoreAudio implementation and validation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this->recovery_.discard_frames( | ||
| static_cast<uint32_t>(this->ring_.available() / this->bytes_per_frame_)); |
There was a problem hiding this comment.
Fixed in c35e82b.
Confirmed and worth catching — it fires on every default-output move and every reopen, not just the unlikely path. discard_ring_tail_() now stops the unit before it counts, so the window between the ring_.available() snapshot and close_unit_()'s stop is gone. close_unit_() sees running_ == false and skips its own stop.
Magnitude was up to one device buffer (~10 ms at 48 kHz) double-counted per recovery event.
| - **`SENDSPIN_SERVER_URL` is answered only for the server_id the dial chose.** `LastDial` has | ||
| no "literal URL, taken at its word" case left; a dial with no id answers nothing. | ||
|
|
||
| ### 27. Native CoreAudio backend — *shipped (hardware pass still owed)* |
There was a problem hiding this comment.
Fixed in c35e82b.
Genuine contradiction and mine. The heading still read shipped (hardware pass still owed) three rounds after the pass was actually run; it is now shipped. The PR description is updated to match.
Stop the render callback before counting the ring tail: a callback draining between the count and close_unit_()'s own stop reported those frames as played on top of the outage gap they had just been charged to. Up to one device buffer per recovery, on every default move and every reopen. Warn when the liveness listener will not register -- nothing else sets device_lost_, so a death would go unnoticed and recovery would never run. Item 27's heading still said the hardware pass was owed, three rounds after it was done, and the DAC-time comment had grown into the rationale essay AGENTS.md forbids; the reasoning is in the roadmap where it belongs.
Item 28 shipped on main while this was in review, so SinkRecovery now refills the budget on a recovered rescan and has rescan_abandoned(). Kept both that and rescan_soon() from here; refill_() clears rescan_immediate_, so a refill from either side starts the ladder clean. Restores item 28's roadmap entry, which 74d94c0 deleted while rewriting the tail of the file for item 26 -- the code shipped, the entry did not survive.
Native CoreAudio on macOS in place of PortAudio (Sendspin#66), the second-outage recovery fix that every device-backed sink needed (Sendspin#67), and direct-address dialling restored to -s beside mdns: discovery (Sendspin#68).



The released macOS binary aborts at launch on any Mac without Homebrew's PortAudio:
CI brewed
portaudioon themacos-14runner and then smoke-tested the.pkgon thatsame 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
ONeverywhere but the macOS release leg.The sink
src/coreaudio_sink.{h,cpp}, anAudioSinkover AUHAL, modelled onportaudio_sink.{h,cpp}and reusing
PcmRingBuffer,pcm_volumeandSinkRecovery/OutageGapHandoffrather thanreimplementing them. The four invariants
portaudio_sink.hwrites down come from theAudioSinkcontract, not from PortAudio, so they carry across unchanged.AudioTimeStamp, converted throughmach_timebase_info(). That timestamp is when the hardware consumes the buffer rather thanwhen it reaches the speaker, so
kAudioDevicePropertyLatency,kAudioStreamPropertyLatencyand the unit's converter latency go on top — unlike PortAudio, where
outputBufferDacTimealready carries them.
kAudioDevicePropertySafetyOffsetis deliberately not in that sum:it is the margin the HAL schedules ahead by, so it is already in how far in the future the
timestamp sits. (It was in the sum at first; Apple's own description of the timestamp on
coreaudio-api, and cubeb — also AUHAL, same structure — settled it. A whole-path latency
figure like mpv's, which also folds in
kAudioDevicePropertyBufferFrameSize, is the wrongthing to add to a per-buffer timestamp.)
letting the AU convert.
capabilities()probes the three ladders by setting each candidateformat on a scratch unit — the same question a stream asks.
-o coreaudio[:<device>], with PortAudio's device grammar exactly, re-resolved per stream.AudioDeviceIDis stable andAudioObjectAddPropertyListenerreportskAudioDevicePropertyDeviceIsAlivedirectly, sothere is no
Pa_Terminate()/Pa_Initialize()and no index renumbering.SinkRecovery's budget: a move isan ordinary event, and spending the reopen on it would leave a real outage in the same
track with nothing left.
CI
with_portaudiojoins the matrix on every leg (a key missing from one reads as false),OFFonmacos-arm64only. That leg'sbrew install portaudio pkgconfis gone — nothingleft on it needs pkg-config — its
expect_backendsisnull, stdout, coreaudio, and itsruntime_packagesis the fact that there are none.And the guard that would have caught this: a macOS step running
otool -Lover the stagedbinary and failing on any load command outside
/usr/liband/System. Worth havingwhatever 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 --checkandshellcheck scripts/*.share clean, andtests/device_spec_test.cppcovers the-o coreaudio[:...]forms and the Linuxreserved-backend refusal on every leg.
This was written on Linux, where it is not built, so CI's
macos-arm64leg was the firstcompiler to see it — and it compiles clean there under
-Werror. That leg'sotool -Lguardreports only CoreAudio, AudioToolbox, CoreFoundation,
libc++andlibSystem: no/opt/homebrewpath, no bundled dylib. 425/425 ctest on macOS, and nobrewstep left.The hardware pass is done — three rounds on a MacBook Pro (M5 Pro, macOS 26.6.2) against
built-in speakers, a USB interface and a DisplayPort monitor. It plays.
otool -Lclean on a machinethat has Homebrew, which is what makes that result mean anything.
on_frames_playeddoes not accumulate error: −35 ppm over five minutes.device (23.40 ms against 1.0 ms). Resampling 44.1→48 kHz added +0.72 ms, which is the audio
unit's converter latency appearing exactly where it should and nowhere else.
restart; a USB device was unplugged and replugged and reopened itself in 190 ms, where the
backoff ladder alone would have waited another 11.5 s.
in phase — the only real test the DAC offset has.
Two things the pass turned up are recorded rather than fixed here:
task. It predates this backend (
39cf29b, item 14) and lives in sharedSinkRecovery, so itchanges all four device-backed sinks and wants its own hardware pass per backend.
The data path was ruled out by reading it, so it points at the device un-muting rather than at
anything here.