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. 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.

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)
try:
    recorder = AudioRecorder(tap_id, "output.wav")
    recorder.start()
    try:
        time.sleep(5)
    finally:
        recorder.stop()
    print(f"Recorded {recorder.duration_seconds:.2f} seconds")
finally:
    destroy_process_tap(tap_id)

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.

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.

See RELEASE.md for the release checklist.

License

MIT

Metadata

Release files for catap 0.5.1

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.1
File Size Uploaded
catap-0.5.1.tar.gz 84.9 kB Details

Built distribution (wheel)

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

Total release size: 141.8 kB

Release files / catap-0.5.1.tar.gz

Download URL catap-0.5.1.tar.gz
Size 84.9 kB
Tags Source
SHA-256 checksum
How to use checksums
fa76e8408bd986e663a69bf125ade51c92e88d70785dded45cdd54b37ef669f8
BLAKE2b-256 checksum
How to use checksums
eb28c56cabbc2b0e26b0a43dbd07bebc3c2389f321eafc0c1b10fafe5359eb9d
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 24, 2026.

Transparency log

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

Download URL catap-0.5.1-py3-none-macosx_14_0_universal2.whl
Size 56.9 kB
Tags Python 3 macOS 14.0+ universal2 (ARM64, x86-64)
SHA-256 checksum
How to use checksums
23595b94e812a9a42ef4b3b4ef00a4126ea4657e0c5fb69a274722eeab0e9e8d
BLAKE2b-256 checksum
How to use checksums
1e77a17c1e1693d442f0deb209ea2e1b779cdfa17912e7169f98157386cd38cc
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 24, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.0

2 release files

0.5.2

2 release files

This release

0.5.1 This release

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