Skip to main content

catap

CI PyPI Python versions License: MIT

Python bindings and recording utilities for Apple's Core Audio process-tap API (macOS 14.2+). catap captures outgoing process audio through Core Audio taps, without installing or selecting a third-party loopback driver.

Install

pip install catap

catap is macOS-only and requires macOS 14.2 or later. Importing it on other platforms raises ImportError. CI covers CPython 3.11 through 3.14, including free-threaded 3.13t and 3.14t, on macOS 15 and 26.

Recording requires the bundled native Core Audio dylib. Wheels include a universal2 build; source builds require the macOS command-line developer tools.

Quick start

CLI:

catap record Safari -d 10 -o safari.wav    # record an app for 10 seconds
catap record --system -d 10 -o mix.wav     # record a global process-output mix
catap record-multitrack Safari Music -o session/   # one synced WAV per app
catap list-apps                            # see what's producing audio

Python:

from catap import record_process

session = record_process("Safari", output_path="safari.wav")
session.record_for(10)

print(f"Recorded {session.duration_seconds:.2f} seconds")

What it does

  • Record one app, several apps mixed through one tap, a global process-output mix, one output device's stream, or an existing visible tap.
  • Record each app as its own sample-synchronized track (multitrack), with one optional input-device track per input stream.
  • Record apps by bundle ID on macOS 26+, surviving app restarts.
  • Exclude selected apps from a global or device recording.
  • Mute tapped apps always or only while the tap is read.
  • Mix captures down to stereo or mono, and create private or visible taps.
  • Retarget a live tap mid-capture: change its process set or mute behavior without interrupting the recording.
  • Watch Core Audio changes (process list, tap list, devices, default output) through a property-listener event layer.
  • Write WAV files, or stream PCM buffers to your own callback.
  • Use bounded queues. Overflow fails the capture and reports dropped buffers and frames instead of growing memory without bound.

Scope

catap is a process-output capture library built on the Core Audio tap API. It is not a general microphone recorder, an AudioServerPlugIn implementation, or a virtual audio driver. Its one input-device path is experimental synchronized microphone capture in multitrack sessions.

The single-tap recorder path reads one tap through one private HAL aggregate device and accepts packed, interleaved linear PCM formats. The multitrack path reads several taps (and optionally one input device) through one aggregate, one stream per track. Non-interleaved, compressed, padded, and other unsupported stream formats fail at startup.

A format change or destruction of an externally owned tap fails capture and discards file output. Equal-format output switches can continue without recorder intervention. Seamless recovery after sleep/wake, format-changing transitions, or loss of the aggregate or IOProc is not implemented. Known background failures set capture_failed and wake wait_for_capture_failure(). stop() performs final validation and cleanup. See docs/core-audio-notes.md.

Usage

Command Line

Common commands:

catap list-apps
catap list-apps --all
catap list-taps
catap list-devices
catap record Safari -d 30 -o safari.wav
catap record Safari Music -d 30 -o both.wav        # two apps, one mixed tap
catap record Spotify --mute -d 60 -o spotify.wav
catap record Spotify --mute-when-tapped -o spotify.wav
catap record --system -e Music -e Zoom -d 30 -o system.wav
catap record --device "MacBook Pro Speakers" -d 30 -o speakers.wav
catap record --tap <uid> -o shared.wav             # an existing visible tap
catap record --bundle-id com.apple.Music -o music.wav   # macOS 26+
catap record-multitrack Safari Music -o session/   # one synced WAV per app
catap record-multitrack Safari --with-mic -o call/ # app + microphone tracks
catap watch                                        # stream Core Audio changes

Run catap record --help and catap record-multitrack --help for the full option set.

Python API

from catap import record_process

# High-level API: catap manages tap creation, startup, shutdown, and cleanup.
session = record_process("Safari", output_path="output.wav")
session.record_for(5)

print(f"Recorded {session.duration_seconds:.2f} seconds")

If you pass on_buffer=..., the callback runs on catap's background worker thread so the Core Audio callback stays lightweight. The callback receives an AudioBuffer with bytes that are safe to keep, frame count, stream format metadata, and Core Audio timing metadata:

from catap import AudioBuffer, record_process

def on_buffer(buffer: AudioBuffer) -> None:
    print(buffer.frame_count, buffer.format.sample_rate, buffer.input_sample_time)

session = record_process("Safari", on_buffer=on_buffer)
session.record_for(5)

Do not call session.stop() or recorder.stop() directly from on_buffer. The callback runs on the worker that stop() must join and finalize. Signal the owning thread instead, then let that thread stop the session:

from threading import Event

from catap import AudioBuffer, record_process

stop_requested = Event()
frames_received = 0

def on_buffer(buffer: AudioBuffer) -> None:
    global frames_received
    frames_received += buffer.frame_count
    if frames_received >= buffer.format.sample_rate * 5:
        stop_requested.set()

session = record_process("Safari", on_buffer=on_buffer)
session.start()
try:
    stop_requested.wait()
finally:
    session.stop()

Once recording has started, session.stream_format exposes the callback AudioStreamFormat without waiting for the next buffer.

Stopping waits for the callback: queued buffers are still delivered to on_buffer during shutdown, and the in-flight callback is joined before stop() returns. A callback that never returns blocks shutdown indefinitely, so keep callbacks bounded and quick.

To control the recording lifetime yourself, use the session as a context manager:

import time
from catap import record_process

with record_process("Safari", output_path="output.wav", mute=False) as session:
    time.sleep(5)

print(f"Recorded {session.duration_seconds:.2f} seconds")

For streaming-only mode, pass on_buffer=... and omit output_path.

By default, catap queues up to 256 pending audio buffers before treating a slow writer or callback as a capture failure. Tune this with max_pending_buffers=... on record_process, record_system_audio, RecordingSession, or AudioRecorder.

A name query that matches multiple processes raises with the candidates in the error rather than picking one arbitrarily.

Capture variants

Process, system-audio, and bundle-ID session builders accept mute= (a bool or TapMuteBehavior), mono=True, and visible=True (create a tap other audio clients can see). Device capture follows the device's native format, so it accepts mute= and visible= but not mono=. Multitrack capture accepts mute= and mono= and creates private taps; a session for an existing tap uses that tap's already-configured options. The CLI exposes the applicable creation options and intentionally limits --mute to app, bundle-ID, and device targets:

from catap import (
    TapMuteBehavior,
    record_bundle_ids,
    record_device,
    record_processes,
    record_system_audio,
)

record_processes(["Safari", "Music"], "both.wav")       # one mixed tap
record_system_audio("mix.wav", exclude=["Zoom"], mono=True)
record_device("MacBook Pro Speakers", "speakers.wav")   # one device stream
record_processes(["Safari"], "s.wav", mute=TapMuteBehavior.MUTED_WHEN_TAPPED)

# macOS 26+: tap by bundle ID; the tap re-attaches if the app restarts.
record_bundle_ids(["com.apple.Music"], "music.wav", restore=True)

Recorder and session paths accept drift_compensation_quality=. None preserves Core Audio's default. Other values must be a DriftCompensationQuality member:

from catap import DriftCompensationQuality, record_system_audio

record_system_audio(
    "mix.wav",
    drift_compensation_quality=DriftCompensationQuality.HIGH,
)

The levels are MINIMUM, LOW, MEDIUM, HIGH, and MAXIMUM. Raw integers and booleans are rejected.

Multitrack capture

record_multitrack records each app as its own sample-synchronized track through one aggregate device. Each app gets one WAV and shares one clock. microphone=True adds one track per input-device stream (experimental; requires microphone permission):

from catap import record_multitrack

session = record_multitrack(["Safari", "Music"], "session/", microphone=True)
session.record_for(30)
print(session.track_labels, session.track_captured_only_silence)

The output directory is created automatically, including missing parents. Explicit output_paths= entries must name distinct destinations; path aliases, including relative-component, case-only, and canonically equivalent Unicode spellings, are rejected before capture starts.

A dropped buffer group, vanished tap, format change, or other track failure discards every track instead of publishing a desynchronized session. Publication is transactional across errors and interruptions handled by the running Python process. An abrupt process kill or power loss during the final multi-file commit is not journal-recovered.

Live tap retargeting

A running session's tap can be modified without interrupting the capture:

session = record_processes(["Safari"], "out.wav")
session.start()
session.set_processes(["Safari", "Music"])   # add Music mid-capture
session.set_mute_behavior(True)              # mute the apps mid-capture
session.stop()

set_processes is for inclusive process-list taps. Global/exclusive taps use their process list as exclusions, and bundle-ID taps have a separate target list, so set_processes rejects both rather than silently reversing or mixing their semantics.

Lower level, get_tap_description(tap_id) / set_tap_description(tap_id, description) expose the same Core Audio property directly. Changing fields that alter the tap's stream format (mono, mixdown, device) mid-capture makes the recorder fail the capture rather than publish mixed-format output.

Watching Core Audio changes

catap.events delivers property-change notifications on a catap-owned dispatcher thread (never the HAL thread):

from catap import events

def on_change(event: events.AudioPropertyEvent) -> None:
    print("audio processes changed", event.selector_fourcc)

with events.watch_audio_processes(on_change):
    ...  # also: watch_audio_taps, watch_audio_devices,
         # watch_default_output_device, watch_tap, watch_property

The CLI equivalent is catap watch.

Mute Behavior

With record_process(..., mute=True), the app stays muted for the lifetime of the recording session. Pass mute=TapMuteBehavior.MUTED_WHEN_TAPPED to mute only while the tap is actually being read. The lower-level mute modes behave differently if the tap outlives the recorder; see docs/mute-behavior.md.

Low-level API

from catap import (
    AudioRecorder,
    TapDescription,
    TapMuteBehavior,
    create_process_tap,
    destroy_process_tap,
    find_process_by_name,
    list_audio_taps,
    record_tap,
)

process = find_process_by_name("Safari")
print(f"Found: {process.name} (PID: {process.pid})")

tap_desc = TapDescription.stereo_mixdown_of_processes([process.audio_object_id])
tap_desc.name = "My Recording"
tap_desc.mute_behavior = TapMuteBehavior.UNMUTED  # or MUTED

import time

tap_id = create_process_tap(tap_desc)
recorder = AudioRecorder(tap_id, "output.wav")
try:
    recorder.start()
    time.sleep(5)
finally:
    try:
        if recorder.is_recording or recorder.needs_cleanup:
            recorder.stop()
    finally:
        if not recorder.needs_cleanup:
            destroy_process_tap(tap_id)

print(f"Recorded {recorder.duration_seconds:.2f} seconds")

If stop() raises while recorder.needs_cleanup is still true, the recorder kept the Core Audio objects it could not safely release. Call stop() again to retry the teardown, and destroy the tap only once cleanup succeeds: a tap created with mute enabled keeps its process muted for as long as the tap exists. The high-level RecordingSession implements this retry contract for you and exposes the same state as session.needs_cleanup; if a session.close() fails, call it again to retry.

If another app has already created a non-private tap, you can discover it and attach a recorder without taking ownership of the tap itself:

from catap import list_audio_taps, record_tap

tap = next(tap for tap in list_audio_taps() if tap.name == "Shared Mix")
session = record_tap(tap, output_path="shared-mix.wav")
session.record_for(5)

Device-targeted taps can be built directly from discovered output streams:

from catap import TapDescription, find_process_by_name, list_audio_devices

process = find_process_by_name("Safari")
device = next(device for device in list_audio_devices() if device.is_default_output)
stream = device.output_streams[0]

tap_desc = TapDescription.of_processes_for_device_stream(
    [process.audio_object_id],
    stream,
)
tap_desc.name = "Safari on default speakers"

Permissions

Core Audio taps require system-audio recording permission. macOS prompts the first time an app starts recording from an aggregate device that contains a tap; if access was previously denied, enable it in System Settings.

When you run from a terminal (for example uv run catap record Spotify), macOS attributes capture to the terminal app, so grant permission to Terminal, iTerm, or whichever host is launching catap.

If permission is missing, macOS still delivers audio buffers — zeroed. A capture that "succeeds" but contains only silence usually means the hosting app was never granted access (or needs a restart after being granted). Check session.captured_only_silence after recording; the CLI prints a warning automatically. Permission changes take effect after the host app restarts.

Modifying a live tap's description (set_tap_description, session.set_processes) requires the same permission and raises PermissionError without it.

Multitrack microphone tracks use the separate Microphone permission. Granting one permission does not grant the other. If microphone tracks have audio while every tap track reports silence, system-audio permission is missing. Check session.track_captured_only_silence.

App bundles using Core Audio taps should include NSAudioCaptureUsageDescription in their Info.plist. Sandboxed apps still need their normal sandbox configuration; Core Audio taps do not add a separate system-audio-capture entitlement.

How it works

  1. Process enumeration: reads kAudioHardwarePropertyProcessObjectList to find audio processes.
  2. Tap creation: builds a CATapDescription through PyObjC and calls AudioHardwareCreateProcessTap.
  3. Aggregate device: creates a private Core Audio aggregate device containing the tap, matching Apple's documented tap-capture path. catap destroys the aggregate when recording stops.
  4. Audio capture: registers the bundled native dylib's AudioDeviceIOProc and copies tap audio into a preallocated native ring.
  5. Worker output: a Python drain thread feeds one background worker per track. Workers write WAV data and invoke optional on_buffer callbacks outside the Core Audio real-time path.

The Core Audio notes live in docs/core-audio-notes.md. Recorder callback and queueing design is in docs/performance.md.

Development

git clone https://github.com/sbetko/catap.git
cd catap
uv sync --group dev

Quality checks

uv run --group dev ruff check .
uv run --group dev ty check --error-on-warning src tests
uv run --group dev pytest
uv run --group dev python -m build
uv run --group dev twine check dist/*

Free-threaded checks:

uv python install 3.13t 3.14t
uv run --python 3.13t --group dev pytest
uv run --python 3.14t --group dev pytest
CATAP_RUN_INTEGRATION=1 uv run --python 3.14t --group dev pytest \
  tests/test_integration.py::test_record_system_audio_smoke

Integration tests

CATAP_RUN_INTEGRATION=1 uv run --group dev pytest -m integration

CATAP_RUN_INTEGRATION=1 runs the structural Core Audio cases. Also set CATAP_RUN_TONE_INTEGRATION=1 for permissioned tone, live mutation, microphone, and bundle-relaunch checks. See RELEASE.md for host requirements and the release gate.

License

MIT

Metadata

Release files for catap 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for catap 0.6.0
File Size Uploaded
catap-0.6.0.tar.gz 159.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for catap 0.6.0
File Interpreter ABI Platform
catap-0.6.0-py3-none-macosx_14_0_universal2.whl Python 3 none macOS 14.0+ universal2 (ARM64, x86-64) Details

Total release size: 262.5 kB

Release files / catap-0.6.0.tar.gz

Download URL catap-0.6.0.tar.gz
Size 159.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e7c057d799c883a4b93a0f0b2e1e081992c356280d6bf2088d94c2a41504cc58
BLAKE2b-256 checksum
How to use checksums
7c0343f3b7cc44c68723409ce92387160e257b0f8747be837e1801ee14cce64d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Release files / catap-0.6.0-py3-none-macosx_14_0_universal2.whl

Download URL catap-0.6.0-py3-none-macosx_14_0_universal2.whl
Size 103.4 kB
Tags Python 3 macOS 14.0+ universal2 (ARM64, x86-64)
SHA-256 checksum
How to use checksums
edbe369be35c0a88233e95b02e5d2ec95e0d1f68031ee2fffe0b51c539c703f3
BLAKE2b-256 checksum
How to use checksums
ecb53673fa17500716bcdb420551de54ce7e8398ad04606dad649a54ef46fc64
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page