Skip to content

docs: system audio is silent unless quill runs as a LaunchAgent - #54

Open
psabbagh wants to merge 2 commits into
digimata:masterfrom
psabbagh:docs/rca-002-system-tap-requires-launchagent
Open

docs: system audio is silent unless quill runs as a LaunchAgent#54
psabbagh wants to merge 2 commits into
digimata:masterfrom
psabbagh:docs/rca-002-system-tap-requires-launchagent

Conversation

@psabbagh

@psabbagh psabbagh commented Aug 2, 2026

Copy link
Copy Markdown

The problem

Run quill from a terminal, as the README's "How to use" step 1 suggests, and system.caf comes out as a full-length file of digital silence. There is no error, no permission prompt, and nothing in quill doctor that reveals it. You find out when the transcript has no them segments — after the meeting.

Evidence

macOS 26.5.2 (25F84), Apple Silicon, Swift 6.3.3, at 855869e. Each take played the same speech through the default output device while recording; levels come from decoding the resulting CAF to 16-bit PCM.

# launched by signature system peak system non-zero mic peak
1 shell stock (linker-signed) −∞ dB 0.0% −10.8 dB
2 shell stock, fresh process −∞ dB 0.0% −13.1 dB
3 shell re-signed, Info.plist bound −∞ dB 0.0% −11.7 dB
4 LaunchAgent re-signed, Info.plist bound −1.6 dB 50.9% −13.6 dB
5 LaunchAgent stock (linker-signed) −0.6 dB

The mic track is healthy throughout, so the sessions themselves are fine.

Why it is invisible

Nothing fails. AudioHardwareCreateProcessTap returns noErr, kAudioTapPropertyFormat reports a correct 2ch/48kHz stream, the aggregate device is created, AudioDeviceStart succeeds, and the IO proc fires at the correct rate for the whole session — take 1 delivered 1,962 packets across 41.8s at 1,024 frames each, matching wall clock exactly. Every sample is zero.

A correctly clocked device delivering well-formed all-zero buffers is an unauthorized tap, not a broken audio graph. No error path in SystemAudioRecorder can observe it.

Cause

TCC evaluates the request against the responsible process. Launched from a terminal, that is the terminal, not quill — so quill has no identity to grant, and because the responsible process already has its own TCC record, no prompt is raised either. Under launchd quill is its own responsible process, macOS prompts with "quill" would like access to record your system audio, and capture works immediately.

Take 3 is the decisive one: with the embedded Info.plist correctly bound by the signature, a shell-launched quill was still silent and still produced no prompt. Binding alone is not sufficient.

Two things ruled out during diagnosis, recorded in the RCA so nobody repeats them:

  • The aggregate device is fine. createAggregateDevice passes an empty sub-device list and no main device, which looks like a missing clock source. Adding the default output device as kAudioAggregateDeviceMainSubDeviceKey and sole sub-device changed nothing. Reverted; not part of this PR.
  • Re-signing is not required. swift build emits a linker-signed binary where the embedded Info.plist is not bound (Info.plist=not bound), which is a real defect but not load-bearing. A completely stock binary captures correctly once it runs under launchd (take 5). The install recipe needs no codesign step.

A related trap worth knowing: system audio is gated on the System Audio Recording Only list, not Screen Recording. The current Gotchas entry sends users to a list quill can never appear in — and a bare binary can't be added by hand through the + picker either.

What this PR changes

Documentation only, no code:

  • --launch-at-login is no longer marked "optional"
  • "How to use" step 1 no longer presents the terminal as equivalent
  • The Gotchas entry pointing at Screen Recording is replaced with an accurate one
  • Adds .issues/rca-002-system-tap-silent-outside-launchagent.md with the full write-up

Notes

I wrote the RCA in the shape of rca-001, since that seemed to be the house convention — happy to drop that file and keep this to the README changes if you would rather .issues/ stay yours, or move it to a regular issue.

The RCA proposes a code fix I have deliberately not included here, to keep this reviewable: a liveness check in SystemAudioRecorder mirroring the one MicRecorder already has from rca-001 — accumulate peak over the first second of tap callbacks and warn loudly if it is exactly zero. Unlike the mic case there is no fallback to switch to, so the right action is to tell the user rather than recover silently. It also notes that doctor could check getppid() == 1 and warn when quill is not running under launchd. Happy to open either as a follow-up if you want them.

One honest gap, recorded in the RCA: on the machine used for testing, quill had prior grant history at the same path, so whether TCC keyed the working grant to the code signature or to the executable path was not isolated. On a machine that has never granted quill, prompt behaviour may differ. The practical claim — a stock binary under launchd obtains and uses a working grant — is demonstrated.

Launched from a terminal, quill's TCC request is attributed to the terminal
rather than to quill. AudioHardwareCreateProcessTap returns noErr, the IO proc
fires at the correct rate for the full session, and every sample is zero — a
full-length silent system.caf with no error and no permission prompt.

Measured across four takes: silent from a shell whether or not the embedded
Info.plist is bound by the signature, and correct under launchd, where macOS
prompts by name. Also corrects the Gotchas entry pointing at Screen Recording,
which is a different TCC service and does not cover process taps.

The RCA records the one untested combination (launchd plus the stock
linker-signed binary), which decides whether install needs a codesign step.
Ran the experiment the RCA left open: a stock swift build binary, linker-signed
with Info.plist not bound, captures system audio correctly once it runs under
launchd (-0.6 dB peak, 54.6% non-zero).

So the embedded-plist binding defect is real but not load-bearing, and the
install recipe needs no codesign step — the fix is documentation plus a
liveness check. Records the residual uncertainty about whether TCC keyed the
grant to the signature or the path, which this machine's prior grant history
could not isolate.
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