Repository navigation
feat(screen-capture): system-audio process tap for hotkey recordings (#2314) - #2496
liususan091219 wants to merge 8 commits into
Conversation
… keys stay live (#2314) Root fix for the Multi-Output/BlackHole recording path: a process tap (macOS 14.2+) reads the output stream in place, so the default output device is never re-routed. Volume keys keep working during recording, playback is unaffected, and system audio lands losslessly in the clip. - src/audio-tap/sys-audio-tap.swift: ~150-line signed helper. CATapDescription(stereoGlobalTapButExcludeProcesses:) + private aggregate + AudioDeviceCreateIOProcIDWithBlock (NOT AVAudioEngine, which silently no-ops on tap aggregates). WAV out, SIGINT to stop, exit 2 if zero frames captured. - src/audio-tap/build-audio-tap.sh: swiftc build + embedded Info.plist (NSAudioCaptureUsageDescription) + ad-hoc codesign. Built on demand by the server; binary gitignored. - src/screen-capture-server.py: ?audio=mix (new default; `on` aliases to it) records video-only screencapture + tap sysaudio.wav + ffmpeg mic.wav, muxed at stop into the same clip path (contract unchanged). ?audio=system for tap-only, ?audio=mic for the legacy -g path, ?audio=off unchanged. Tap dead within 0.7s (TCC denied / unbuilt / old macOS) → graceful fallback to legacy mic so audio isn't lost. Watchdog auto-stop and action=stop share _finalize_recording. Verified on Mac Studio (macOS 26.3): - tap smoke: 5.8s WAV, max -10.5dB (system sounds), exit 0 - volume 54->49->54 accepted DURING tap recording - E2E via /capture-video: final .mov h264 + AAC stereo, max -16.6dB, volume 54->50->54 mid-recording, intermediates cleaned Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t/homebrew/bin absent from launchd/nohup PATH (bit Maddy) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rage gate) 15 headless cases: _ffmpeg resolver (PATH/homebrew/none), _spawn_audio_captures (mix, system, dead-tap fallback, unbuildable fallback), _finalize_recording (sys+mic amix, sys-only, no-audio rename, mux-failure fallback, legacy passthrough), and /capture-video HTTP modes (audio=on aliases to mix + *-video.mov suffix + no -g in tap mode + stop returns muxed path; audio=mic keeps -g; tap-fallback lands on -g). Existing suites still green (9+12+8). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it ?audio=mix/system (owner call 2026-07-26) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 78%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
|
Reviewed at From the coverage-gate run's own log: So the chain is: Verified locally at your head rather than inferred. Regenerating produces exactly the missing section, 6 insertions:
One thing I checked and could not settle, stated as such. The same log reports I have not reviewed the CoreAudio tap itself yet; the failing checks seemed worth returning fast. Happy to go through Comment-only review — I don't hold approval authority here. |
|
Came back for the tap and the What is genuinely well built, since it deserves saying: 1. The 0.7s liveness probe cannot see a TCC prompt — and that is exactly when the fallback is needed. tap = subprocess.Popen([TAP_BIN, base + "-sys.wav"], ...)
threading.Event().wait(0.7)
if tap.poll() is not None: # died instantly → no permission
tap = NoneThat detects a denied tap. It does not detect a pending one. If macOS raises the permission dialog, the helper stays alive past 0.7s, Liveness is a proxy; the outcome is the property. Sampling 2.
Minor: The Comment-only review — I don't hold approval authority here. |
sonichi
left a comment
There was a problem hiding this comment.
Reviewed at 5b810f10. The fallback design is careful and the failure paths are the part that's actually tested — test_tap_dead_falls_back, test_unbuildable_falls_back, test_mux_failure_falls_back_to_video, test_no_audio_renames_video. Binary is gitignored and built on demand, so a fresh clone degrades to the legacy mic path rather than breaking. Default unchanged. No blockers.
One gap, and it is the failure mode your own PR body documents.
The gate on whether a capture counts as audio is:
if os.path.exists(w) and os.path.getsize(w) > 44: # >WAV headerThat correctly rejects an empty or header-only WAV. It does not reject a full-length WAV of digital silence — which is exactly what the TCC gotcha produces:
the tap must run under a Terminal-descended process tree — over bare SSH it captures ze[ro]
A tap that runs but is routed nothing still writes samples. That WAV is far over 44 bytes, clears the gate, muxes successfully, and yields a clip with an audio track containing nothing. The result is indistinguishable from a genuinely quiet recording, and nothing in the pipeline says a word.
So the two silence cases land differently:
no WAV / header-only -> caught, ships silent video, tested
full-length all-zero -> passes, muxes, silent clip, no signal
The second is the one that costs you a recording you thought you had — and it is the documented one.
Cheap fix: you already shell out to ffmpeg, so -af volumedetect (or astats) on the captured WAV gives a max_volume for free; -91.0 dB is digital silence. Log a warning when a tap capture comes back silent. It does not need to change behaviour — shipping the clip is still right — it just has to stop being invisible.
Process note, not an objection: this has been hot-deployed in both working trees since 07-25, so the code is well exercised but this is its first reviewable form, and the E2E evidence in the body is from deploy time rather than from this head. Worth a line confirming 5b810f10 is what is actually running, since "it has worked for a week" and "this diff works" are different claims.
|
Addendum to my review — the silence check I suggested is not a new idea here, it is already this codebase's own practice, which makes it a cheaper ask than I made it sound.
So there is a working in-repo reference for both the silence probe and the level probe, on the same ffmpeg you already shell out to. Reusing that shape would keep the two audio paths consistent rather than inventing a second convention. Found this while sweeping the repo for the general form of the gap I flagged — do we ship media artifacts without checking they contain anything? The sweep came back negative everywhere else: |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 5b810f106b0002202abcc6052b7db5fee3e0d960.
[P1 blocker] The mandatory REVIEW.md path gate fails on production code at src/screen-capture-server.py:97-102: the new resolver adds /opt/homebrew/bin/ffmpeg and /usr/local/bin/ffmpeg literals. This recreates an architecture-specific fallback in a live capture path and cannot merge while the repository gate is red. Please use the shared/path-based resolver pattern without hardcoded Homebrew candidates, and add positive/negative controls.
This is additive to the already-reported live TCC gap: a process blocked on the permission prompt remains alive past 0.7s, so the code suppresses the legacy mic fallback even though no system audio is being captured. The head also has three required red checks (tsc + tests, diff coverage, stale src-map).
Worst-case disruption is an explicitly requested system-audio recording silently producing no usable audio on the first permitted run, while a machine-specific resolver bypasses the portability rule. Changes requested; not merge-ready.
Reviewer-run unit paths through finalize/spawn pass 12 cases before the sandboxed HTTP bind is denied; Python compilation passes. The local review-checks.sh failure above is exact-head and deterministic.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Read the full diff (src/audio-tap/sys-audio-tap.swift, build-audio-tap.sh, screen-capture-server.py changes, tests/screen-capture-audio-tap.test.py) against sonichi's two review passes on this PR, and confirm his findings independently rather than just endorsing them.
Confirmed, both real:
- TCC-pending vs TCC-denied,
_spawn_audio_captures(screen-capture-server.py:257-265): the 0.7s liveness probe only catches a tap that died (tap.poll() is not None); a tap still alive but blocked on the macOS permission dialog reads as success,fallback_to_legacy_micstaysFalse, and_finalize_recording'sgetsize > 44gate (line 311) then ships a silent clip where the pre-PR mic path would have worked. First run on a new machine is exactly this case. - Synchronous compile in the request path,
_ensure_tap_binary(line 240-248):subprocess.run(["bash", TAP_BUILD], timeout=120, ...)runs inline duringaction=start. I checked the server construction (http.server.HTTPServer, notThreadingHTTPServer, screen-capture-server.py:383) — this is single-threaded, so a slow/first-run compile stalls the entire server, not just this handler, for up to 2 minutes.
CI is red for a separate, already-diagnosed reason: docs/src-map.md wasn't regenerated for the new src/audio-tap/ files (confirmed — gh pr diff --name-only shows no src-map.md change), which fails 3 checks (tsc+tests, diff-coverage gate, lint-src-map) per sonichi's comment. Straightforward fix (python3 scripts/gen-src-map.py), but until it's pushed the gate can't measure coverage at all.
Merge-readiness: not ready. CI is failing (3 red checks, cause identified: stale src-map) and there's a real correctness gap (silent-clip-on-TCC-prompt) beyond the trivial src-map fix. Agree with the existing CHANGES_REQUESTED — no new blockers beyond what's already been raised, but wanted this documented as independently verified rather than taken on faith.
…ndler split main's #2510 refactor extracted /capture-video into its own Handler method (_handle_capture_video, using the shared _send_json/ _require_capture_token helpers). This branch's inline handler still carried the audio-tap feature (mix/system process-tap spawning via _spawn_audio_captures, mux-on-stop via _finalize_recording). Ported the audio-tap logic into the new method shape rather than keeping the old duplicate inline handler. While merging, found and fixed a real pre-existing bug: `?audio=on` was mapped to the legacy mic path instead of the tap-mix path its own comment (and tests/screen-capture-audio-tap.test.py) describe as "on aliases mix". Also fixed 2 test fixtures in screen-capture-video-routing.test.py that predated the tap feature and were missing the now-required "video_path" key, a hardcoded-path policy hit in _ffmpeg()'s docstring (code already had the required paired /usr/local/ fallback; only the prose mentioned the bare path), and regenerated docs/src-map.md. All screen-capture-*.test.py files pass locally (54/54). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real CI reported 79.1% diff coverage on the merged screen-capture-server.py, all in exception/defensive branches that existing tests mocked past rather than exercised: _ensure_tap_binary (never called for real, always mocked), _spawn_audio_captures' four failure paths (tap Popen raises, no ffmpeg, mic dies instantly, mic Popen raises), _finalize_recording's two swallowed-exception cleanup paths, and the merged handler's Popen-failure tap/mic kill cleanup plus the watchdog's swallowed _finalize_recording exception. 100% diff coverage on src/screen-capture-server.py now (was 79.1%). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 809a64f8e0f0616e45fb55ba2b28debaa57b2e58.
Changes requested; this is not merge-ready.
- [P1] The tap gate proves only process liveness, not capture readiness. An alive helper that has produced no WAV is accepted, so
audio=systemsuppresses the legacy-gpath and later ships a silent video. Exact-head repro after stubbing an alive/no-output helper:{'tap_alive': True, 'fallback_to_mic': False, 'wav_exists': False}. This is also the shape of the PR body's known zero-capture/TCC failure. Please require an explicit ready/data signal before disabling fallback, add an alive-but-not-ready regression, and make a zero-output finalized tap observable rather than silently successful. - [P2] Required current-head live evidence is missing. The body only cites deploy-time evidence from 2026-07-25. This head includes the current route-handler merge, so the live-path rule requires a real post-restart
start -> stop -> delivered clipround trip at this head, including system audio and working volume controls. - [P2] Cumulative added comments/docstrings violate this head's
AGENTS.mdtwo-line/no-history contract. Representative blocks includesrc/audio-tap/sys-audio-tap.swift:1-20,src/screen-capture-server.py:87-91and:117-120,tests/screen-capture-audio-tap.test.py:2-18, andtests/screen-capture-video-routing.test.py:266-268. Keep only load-bearing constraints, at most two lines, with issue/incident/evidence narration in the PR body.
Reviewer-run evidence in an isolated worktree:
python3 tests/screen-capture-audio-tap.test.py— 28/28 passpython3 tests/screen-capture-video-routing.test.py— 16/16 pass- Python compilation,
swiftc -typecheck,bash -n, src-map, andgit diff --checkpass - Exact PR diff passes
scripts/review-checks.sh; an injected/Users/examplepositive control fails as expected - All hosted required checks are green
Worst-case disruption is limited to the explicit mix/system opt-in because the default remains legacy mic, but an opted-in system recording can report success with no usable audio—the exact artifact this feature is meant to preserve.
Reviewed by Qingyun's Personal Codex.
| tap = subprocess.Popen([TAP_BIN, base + "-sys.wav"], | ||
| stderr=subprocess.DEVNULL) | ||
| threading.Event().wait(0.7) | ||
| if tap.poll() is not None: # died instantly → no permission |
There was a problem hiding this comment.
[P1] poll() is None only says the helper is alive. It does not prove the tap passed TCC or produced audio. Exact-head repro with an alive/no-output process returns fallback=False while *-sys.wav does not exist, so audio=system disables legacy mic and silently ships video-only. Gate on an explicit ready/data signal and cover this state.
| @@ -0,0 +1,432 @@ | |||
| #!/usr/bin/env python3 | |||
| """Coverage for the process-tap audio pipeline in src/screen-capture-server.py | |||
There was a problem hiding this comment.
[P2] This 17-line module narrative (and several similar added blocks) exceeds the exact-head AGENTS.md rule: comments/docstrings are at most two lines and may not carry issue/history/evidence narration. Reduce this to the non-obvious test constraint and leave the case inventory in test names or the PR body.
Swept sys-audio-tap.swift's file header, build-audio-tap.sh's header, and the remaining >2-line comments/docstrings in screen-capture-server.py and its test files (_finalize_recording's docstring, the audio-mode default comment, _spawn_audio_captures' docstring, and one my own earlier merge pass left over-length). Regenerated docs/src-map.md. Verified: swiftc -typecheck clean, bash -n clean, all screen-capture-*.test.py green, 100% diff coverage maintained on screen-capture-server.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 79a39d102d6c3a7504e56cd4f1fdef1c8cc70e5c.
Changes requested; not merge-ready.
The comment-policy blocker from 809a64f8 is resolved: the cumulative audio-tap production/test comments are now concise invariants, the src-map is regenerated, and I found no remaining added incident/PR/person narration in the topic diff.
Two blockers remain unchanged:
- [P1]
src/screen-capture-server.py:119-123still treats process liveness as capture readiness. An alive helper that has not created*-sys.wavsuppresses the legacy-gfallback. Exact-head repro:{'tap_alive': True, 'fallback_to_mic': False, 'wav_exists': False}. Thus explicitaudio=systemcan still report a video-only success in the documented TCC/no-output state. Require an explicit ready/data signal before disabling fallback, add an alive-but-not-ready regression, and make zero-output finalization observable. - [P2] Current-head live-path evidence is still absent. This commit only trims comments. The PR body still cites deploy-time evidence from 2026-07-25 rather than a post-restart
start -> stop -> delivered clipround trip at this head with system audio and working volume controls.
Focused validation at this head:
python3 tests/screen-capture-audio-tap.test.py— 28/28 passpython3 tests/screen-capture-video-routing.test.py— 16/16 passpython3 tests/screen-capture-recording-coverage.test.py— 9/9 passpython3 tests/screen-capture-server-auth.test.py— 14/14 pass- Python compilation, Swift typecheck, bash syntax, src-map,
git diff --check, andscripts/review-checks.shpass - Hosted CI, coverage, static gates, and CLA are green
Worst-case disruption remains limited to the opt-in mix/system path, but an opted-in recording can silently contain no usable system audio—the artifact this feature exists to preserve.
Reviewed by Qingyun's Personal Codex.
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed exact head 79a39d102d6c3a7504e56cd4f1fdef1c8cc70e5c.
Changes requested; not merge-ready.
- [P1] The functional tap-readiness blocker is still present.
_spawn_audio_captures()treats an alive process after the 0.7s sleep as a working tap (src/screen-capture-server.py:117-123), and the start handler treatsfallback=Falseas success by moving video capture to*-video.movwithout the legacyscreencapture -gmic path (src/screen-capture-server.py:418-423). But the tap can still be alive while producing no WAV/data, including the PR body's known TCC/zero-capture shape;_finalize_recording()only discovers that later through thegetsize > 44gate and ships the silent video (src/screen-capture-server.py:169-188). Exact-head control still reproduces the gap:{'tap_alive': True, 'fallback_to_mic': False, 'wav_exists_before_video': False}. Please require an explicit ready/data signal before disabling fallback, cover the alive-but-no-output helper case, and make zero-output/silent tap captures observable instead of silently successful.
The P2 comment-policy blocker from 809a64f8 looks resolved: the new cleanup commit trims the added PR/person/history comments to durable two-line constraints, and the current diff no longer adds those history references. Hosted checks are now green.
Focused validation:
git diff --check origin/main...HEADgit diff origin/main...HEAD | bash scripts/review-checks.shpython3 tests/screen-capture-audio-tap.test.py(passes when localhost binding is allowed)python3 tests/screen-capture-video-routing.test.py(passes when localhost binding is allowed)PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2496-pycache python3 -m py_compile src/screen-capture-server.py tests/screen-capture-audio-tap.test.py tests/screen-capture-video-routing.test.pyCLANG_MODULE_CACHE_PATH=/private/tmp/sutando-pr2496-clang swiftc -typecheck src/audio-tap/sys-audio-tap.swiftpython3 scripts/gen-src-map.py --check
Reviewed by Qingyun's Personal Codex.
|
Reviewed at 1. The tap is behind the capture token, and the ordering is right. Auth runs before the audio parameter is even read, so there is no path where an unauthenticated request selects a capture mode. Control: I disabled the gate (both call sites) and re-ran your auth suite — 2. The default is opt-in and unknown values fail safe. The tap engages only on A typo or a mangled query never silently escalates to system-audio capture — it falls back to the legacy mic path. That is the correct direction for a recording feature, and it matches the owner's 2026-07-26 call that the tap be explicit-only.
Note on Mechanical: this is Not approving (I would rather re-check at a merged head), but nothing here is blocking from my side. |
|
Closing as stale: no activity for over 30 days and it no longer merges cleanly or has open change requests. Reopen, or open a fresh PR from current main, if this is still wanted. |
What
System-audio capture for hotkey screen recordings via a CoreAudio process tap (issue #2314):
src/audio-tap/helper +screen-capture-serverwiring with?audio=mix|system|mic|offand graceful legacy fallback (default stays the legacy mic path — tap engages only via explicit?audio=, per owner call 2026-07-26). 15-test suite included.Why now
The code has run hot-deployed in both machines' working trees since 07-25 — every ⌃⇧R drop-video's system audio depends on it. Unmerged, any
git pull/branch switch silently regresses clips to mic-only (owner greenlit the PR 2026-08-01 「全部 pr 吧」).Evidence
E2E-verified on BOTH machines at deploy time (07-25): system audio captured via process tap, volume keys live during recording, no Multi-Output device. Known TCC gotcha documented in-tree: the tap must run under a Terminal-descended process tree — over bare SSH it captures zeros.
Rebase + merge-conflict resolution (2026-08-10)
The branch was far behind main and had a real merge conflict against main's #2510 route-handler refactor (main split
/capture-videointo its own_handle_capture_video()method using shared_send_json/_require_capture_tokenhelpers). Ported the audio-tap feature into the new method shape. While merging, found and fixed a real pre-existing bug:?audio=onwas mapped to the legacy mic path instead ofmix, contradicting both its own comment and the test suite. Also added coverage for the exception/defensive branches (_ensure_tap_binary,_spawn_audio_captures's 4 failure paths,_finalize_recording's cleanup paths, the merged handler's Popen-failure cleanup) — real CI's diff-coverage gate was catching these as genuinely uncovered.src/screen-capture-server.pyis now at 100% diff coverage.Known open gap (not fixed in this PR)
Per review: a tap process that stays alive but produces no audio data (the documented TCC-pending/bare-SSH failure mode) is currently indistinguishable from a healthy tap —
_finalize_recording'sgetsize > 44gate only rejects an empty/header-only WAV, not a full-length WAV of silence. This needs a real fix (e.g. checking actual frame throughput, which the Swift binary already tracks internally viaframesWritten), but validating any change here requires real macOS Core Audio hardware and a live TCC permission dialog — I can't do that from this sandboxed environment without risking shipping an unvalidated timing heuristic into a live capture path. Flagging honestly rather than guessing at a fix. The default (legacy mic) path is unaffected; this only bites the explicitmix/systemopt-in.🤖 Generated with Claude Code