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; importing it on other platforms raises ImportError. It targets macOS 14.2 and newer. CI covers CPython 3.11 through 3.14 plus free-threaded CPython 3.13t and 3.14t on macOS. Current development is on Apple Silicon and macOS 26.2.

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 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 a single app, a global process-output mix, or an existing visible tap.
  • Exclude selected apps from a global recording.
  • Mute an app while recording it, so it's captured but not played aloud.
  • Target a specific output device stream when building a tap.
  • Write WAV files, or stream PCM buffers to your own callback.
  • Use a bounded audio queue: if the worker falls behind, buffers are dropped and the count is reported on stop, instead of growing memory without bound.

Scope

catap is a process-output capture library. It is not a microphone/input-device recorder, an AudioServerPlugIn implementation, or a virtual audio driver.

The current recorder path reads one tap through one private HAL aggregate device. It accepts packed, interleaved linear PCM tap formats and rejects non-interleaved, multi-buffer, compressed, padded, or otherwise unusual formats.

The --system and record_system_audio() paths build a global Core Audio tap: they capture process output that Core Audio exposes to taps. Long-running captures across sleep/wake, route changes, source-process restarts, and default output-device changes are not covered yet. If the tap's stream format changes mid-capture (for example after a default-device change), or a shared tap is destroyed by its owner, stop() fails and discards the output instead of publishing a corrupt or truncated file. See docs/core-audio-notes.md for the short Core Audio notes.

Usage

Command Line

Common commands:

catap list-apps
catap list-apps --all
catap record Safari -d 30 -o safari.wav
catap record Spotify --mute -d 60 -o spotify.wav
catap record --system -e Music -e Zoom -d 30 -o system.wav

Run catap record --help for the full set of recording options.

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.

Mute Behavior

With record_process(..., mute=True), the app stays muted for the lifetime of the recording session. 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.

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 the background worker, which writes WAV data and invokes 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 smoke test

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

Opt-in. Exercises the real macOS Core Audio bridge: process enumeration and a short recording that covers tap startup, shutdown, and WAV finalization. Additionally setting CATAP_RUN_TONE_INTEGRATION=1 plays a short tone aloud and requires the capture to contain it; see RELEASE.md for the release gate.

See RELEASE.md for the release checklist.

License

MIT

Metadata

Release files for catap 0.5.2

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.5.2
File Size Uploaded
catap-0.5.2.tar.gz 93.3 kB Details

Built distribution (wheel)

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

Total release size: 153.3 kB

Release files / catap-0.5.2.tar.gz

Download URL catap-0.5.2.tar.gz
Size 93.3 kB
Tags Source
SHA-256 checksum
How to use checksums
96329a4f580761c01ebaa896659adc6cd667fef6123fe5b983da31b3b0ba23ee
BLAKE2b-256 checksum
How to use checksums
b00f65c14786ed1602354c2e957f98ddcf375f05d34be9a170abfc41614e2884
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.5.2-py3-none-macosx_14_0_universal2.whl

Download URL catap-0.5.2-py3-none-macosx_14_0_universal2.whl
Size 60.0 kB
Tags Python 3 macOS 14.0+ universal2 (ARM64, x86-64)
SHA-256 checksum
How to use checksums
fd1363bc4b4fd07843bbdac9b268f3333acdcd6d3e9a03ab7e5dd0a9b18b8ea2
BLAKE2b-256 checksum
How to use checksums
18b79ad5e7f7ba495cfba5ba9213180dfac692a45ba430fbef84d5ba27086270
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

0.6.0

2 release files

This release

0.5.2 This release

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