Capture system audio through a Core Audio process tap #7

Merged
erdoar merged 2 commits from claude/system-capture into main 2026-10-10 02:06:18 +00:00
erdoar commented 2026-10-10 01:57:52 +00:00 (Migrated from github.com)

Summary

  • SystemAudioTap captures every process except Plug, using CATapDescription with the stereo global tap and Plug's own process excluded. It mutes the captured audio at the speakers, so the only copy heard is the one through Plug's effects. The tap is read through a private aggregate device clocked by Plug's output device, with drift compensation.
  • PlugRingBuffer is a new plain-C, lock-free single-producer/single-consumer ring buffer. It carries audio from the capture I/O thread to the render thread with a ~20 ms prefill. It doesn't allocate, lock or block on either thread.
  • AudioEngine gains an input mixer fed by the test tone and the capture node. The mixer handles sample-rate conversion. setInput(_:) switches between the two, and capture runs only while the engine plays, so other apps are never left muted.
  • The app has an Input picker (Test tone / System audio) and a note about the permission prompt.
  • Minimum macOS is now 14.2, which process taps need, in both Package.swift and Info.plist.

Tested

  • Mac mini M4, macOS 15, Swift 6.1.2: the package builds and scripts/bundle.sh --open runs. System audio is captured and plays through the effects, as reported by the owner.
  • Linux, Swift 6.0.3: 14 tests pass, 7 of them for the ring buffer. That includes a two-thread stress test checking that 200,000 frames arrive once, in order. It also passes under ThreadSanitizer with no races.

Known limits

  • The capture is clocked by Plug's output device when it starts. Switching output mid-capture keeps the old clock, which may cause occasional clicks. Stop and Start resyncs.
  • The ad-hoc signature means macOS may ask for permission again after each rebuild.
  • Single-app capture isn't included yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_011C96TJ2L7nzVMFTH7raNu5

## Summary - `SystemAudioTap` captures every process except Plug, using `CATapDescription` with the stereo global tap and Plug's own process excluded. It **mutes** the captured audio at the speakers, so the only copy heard is the one through Plug's effects. The tap is read through a private aggregate device clocked by Plug's output device, with drift compensation. - `PlugRingBuffer` is a new plain-C, lock-free single-producer/single-consumer ring buffer. It carries audio from the capture I/O thread to the render thread with a ~20 ms prefill. It doesn't allocate, lock or block on either thread. - `AudioEngine` gains an input mixer fed by the test tone and the capture node. The mixer handles sample-rate conversion. `setInput(_:)` switches between the two, and capture runs **only while the engine plays**, so other apps are never left muted. - The app has an **Input** picker (Test tone / System audio) and a note about the permission prompt. - Minimum macOS is now 14.2, which process taps need, in both `Package.swift` and `Info.plist`. ## Tested - Mac mini M4, macOS 15, Swift 6.1.2: the package builds and `scripts/bundle.sh --open` runs. System audio is captured and plays through the effects, as reported by the owner. - Linux, Swift 6.0.3: 14 tests pass, 7 of them for the ring buffer. That includes a two-thread stress test checking that 200,000 frames arrive once, in order. It also passes under ThreadSanitizer with no races. ## Known limits - The capture is clocked by Plug's output device when it starts. Switching output mid-capture keeps the old clock, which may cause occasional clicks. Stop and Start resyncs. - The ad-hoc signature means macOS may ask for permission again after each rebuild. - Single-app capture isn't included yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011C96TJ2L7nzVMFTH7raNu5
Sign in to join this conversation.
No description provided.