Files
beefcarver e30ca09627 refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
2026-11-03 16:10:25 -05:00

577 lines
20 KiB
Python

"""Native macOS system-audio capture via Core Audio process taps.
macOS 14.2 added *process taps* to the HAL: a process may ask ``coreaudiod``
for a copy of the audio that is being rendered, without a kernel extension, a
virtual loopback (BlackHole, Loopback, Soundflower, …), a Multi-Output Device
or a helper binary. The tap is driven here straight through Core Audio's C
API with :mod:`ctypes`, so the only dependency is macOS itself.
Capture is a three step dance, all of it native:
1. a ``CATapDescription`` (global stereo mix-down, unmuted) is turned into a
tap object with ``AudioHardwareCreateProcessTap``;
2. the tap is wrapped in a *private* aggregate device so it can be read like
any other input device;
3. an IOProc on that aggregate device delivers interleaved ``float32`` frames
from the HAL's real-time thread, which are buffered for the analyser.
Tapping other applications' audio is privacy sensitive: the first attempt
makes macOS ask for **System Audio Recording** permission (System Settings ▸
Privacy & Security). Until that is granted the tap is created successfully
but delivers digital silence, so callers must treat a silent tap as a failed
capture.
The module is importable everywhere; :func:`available` is ``False`` off macOS
or on systems older than the tap API.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import sys
import threading
import time
import uuid
from collections import deque
from typing import Any
__all__ = ["SystemAudioTap", "TapError", "available"]
#: ``kAudioObjectPropertyScopeGlobal`` / ``kAudioObjectPropertyElementMain``.
_SCOPE_GLOBAL = int.from_bytes(b"glob", "big")
_ELEMENT_MAIN = 0
#: Tap object property selectors (``CoreAudio/AudioHardware.h``).
_TAP_UID = int.from_bytes(b"tuid", "big")
_TAP_FORMAT = int.from_bytes(b"tfmt", "big")
#: ``kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked``.
_FORMAT_LPCM = int.from_bytes(b"lpcm", "big")
_FLAG_FLOAT = 0x1
#: Aggregate-device description keys (``CoreAudio/AudioHardware.h``).
_AGGREGATE_UID = "uid"
_AGGREGATE_NAME = "name"
_AGGREGATE_PRIVATE = "private"
_AGGREGATE_STACKED = "stacked"
_AGGREGATE_TAPS = "taps"
_AGGREGATE_TAP_AUTOSTART = "tapautostart"
_SUB_TAP_UID = "uid"
_SUB_TAP_DRIFT = "drift"
_COREAUDIO = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio"
_COREFOUNDATION = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
_UTF8 = 0x08000100
#: Seconds of audio kept in the hand-off buffer before the oldest is dropped.
_BUFFER_SECONDS = 1.0
class TapError(RuntimeError):
"""The native audio tap could not be created."""
class _PropertyAddress(ctypes.Structure):
_fields_ = [
("mSelector", ctypes.c_uint32),
("mScope", ctypes.c_uint32),
("mElement", ctypes.c_uint32),
]
class _StreamFormat(ctypes.Structure):
"""``AudioStreamBasicDescription``."""
_fields_ = [
("mSampleRate", ctypes.c_double),
("mFormatID", ctypes.c_uint32),
("mFormatFlags", ctypes.c_uint32),
("mBytesPerPacket", ctypes.c_uint32),
("mFramesPerPacket", ctypes.c_uint32),
("mBytesPerFrame", ctypes.c_uint32),
("mChannelsPerFrame", ctypes.c_uint32),
("mBitsPerChannel", ctypes.c_uint32),
("mReserved", ctypes.c_uint32),
]
class _AudioBuffer(ctypes.Structure):
_fields_ = [
("mNumberChannels", ctypes.c_uint32),
("mDataByteSize", ctypes.c_uint32),
("mData", ctypes.c_void_p),
]
class _AudioBufferList(ctypes.Structure):
# The HAL hands us a variably sized struct; mNumberBuffers says how many
# entries are valid. The array is oversized so indexing stays in bounds
# for the (few) buffers a tap aggregate ever reports.
_fields_ = [
("mNumberBuffers", ctypes.c_uint32),
("mBuffers", _AudioBuffer * 8),
]
_IOProc = ctypes.CFUNCTYPE(
ctypes.c_int32,
ctypes.c_uint32, # inDevice
ctypes.c_void_p, # inNow
ctypes.POINTER(_AudioBufferList), # inInputData
ctypes.c_void_p, # inInputTime
ctypes.c_void_p, # outOutputData
ctypes.c_void_p, # inOutputTime
ctypes.c_void_p, # inClientData
)
_loaded: dict[str, Any] = {}
_load_lock = threading.Lock()
def _frameworks() -> tuple[ctypes.CDLL, ctypes.CDLL, ctypes.CDLL]:
"""Load (and cache) CoreAudio, CoreFoundation and the Objective-C runtime."""
with _load_lock:
if not _loaded:
ca = ctypes.CDLL(_COREAUDIO)
cf = ctypes.CDLL(_COREFOUNDATION)
objc = ctypes.CDLL(ctypes.util.find_library("objc") or "libobjc.A.dylib")
_configure(ca, cf, objc)
_loaded.update(ca=ca, cf=cf, objc=objc)
return _loaded["ca"], _loaded["cf"], _loaded["objc"]
def _signature(lib: ctypes.CDLL, name: str, arguments: list, result: Any) -> None:
"""Declare a C function, skipping it when this macOS does not have it."""
function = getattr(lib, name, None)
if function is None:
return
function.argtypes = arguments
function.restype = result
def _configure(ca: ctypes.CDLL, cf: ctypes.CDLL, objc: ctypes.CDLL) -> None:
_signature(
ca,
"AudioObjectGetPropertyData",
[
ctypes.c_uint32,
ctypes.POINTER(_PropertyAddress),
ctypes.c_uint32,
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_uint32),
ctypes.c_void_p,
],
ctypes.c_int32,
)
_signature(
ca,
"AudioHardwareCreateProcessTap",
[ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)],
ctypes.c_int32,
)
_signature(ca, "AudioHardwareDestroyProcessTap", [ctypes.c_uint32], ctypes.c_int32)
_signature(
ca,
"AudioHardwareCreateAggregateDevice",
[ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)],
ctypes.c_int32,
)
_signature(
ca, "AudioHardwareDestroyAggregateDevice", [ctypes.c_uint32], ctypes.c_int32
)
_signature(
ca,
"AudioDeviceCreateIOProcID",
[ctypes.c_uint32, _IOProc, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)],
ctypes.c_int32,
)
_signature(ca, "AudioDeviceDestroyIOProcID", [ctypes.c_uint32, ctypes.c_void_p], ctypes.c_int32)
_signature(ca, "AudioDeviceStart", [ctypes.c_uint32, ctypes.c_void_p], ctypes.c_int32)
_signature(ca, "AudioDeviceStop", [ctypes.c_uint32, ctypes.c_void_p], ctypes.c_int32)
_signature(
cf,
"CFStringCreateWithCString",
[ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32],
ctypes.c_void_p,
)
_signature(
cf,
"CFDictionaryCreateMutable",
[ctypes.c_void_p, ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p],
ctypes.c_void_p,
)
_signature(
cf,
"CFDictionarySetValue",
[ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p],
None,
)
_signature(
cf,
"CFArrayCreate",
[ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_long, ctypes.c_void_p],
ctypes.c_void_p,
)
_signature(cf, "CFRelease", [ctypes.c_void_p], None)
_signature(objc, "objc_getClass", [ctypes.c_char_p], ctypes.c_void_p)
_signature(objc, "sel_registerName", [ctypes.c_char_p], ctypes.c_void_p)
def _sender(result: Any, arguments: tuple[Any, ...]):
"""A typed ``objc_msgSend`` for one method signature (arm64 needs this)."""
key = f"msg:{result}:{arguments}"
objc = _frameworks()[2]
with _load_lock:
sender = _loaded.get(key)
if sender is None:
prototype = ctypes.CFUNCTYPE(
result, ctypes.c_void_p, ctypes.c_void_p, *arguments
)
sender = prototype(("objc_msgSend", objc))
_loaded[key] = sender
return sender
def _selector(name: bytes) -> int:
return int(_frameworks()[2].sel_registerName(name) or 0)
def _send_object(receiver: int, name: bytes, *arguments: int) -> int:
sender = _sender(ctypes.c_void_p, (ctypes.c_void_p,) * len(arguments))
return int(sender(receiver, _selector(name), *arguments) or 0)
def _send_flag(receiver: int, name: bytes, value: bool) -> None:
_sender(None, (ctypes.c_bool,))(receiver, _selector(name), value)
def _send_integer(receiver: int, name: bytes, value: int) -> None:
_sender(None, (ctypes.c_long,))(receiver, _selector(name), value)
def _osstatus(code: int) -> str:
"""Render an ``OSStatus`` as its four-character code when it has a printable one."""
raw = (int(code) & 0xFFFFFFFF).to_bytes(4, "big")
if all(32 <= byte < 127 for byte in raw):
return f"{raw.decode()!r}"
return str(int(code))
def available() -> bool:
"""True when this machine can create Core Audio process taps."""
if sys.platform != "darwin":
return False
try:
ca = _frameworks()[0]
except OSError:
return False
return hasattr(ca, "AudioHardwareCreateProcessTap")
class SystemAudioTap:
"""Records what the default output device is playing (no driver required).
``start()`` raises :class:`TapError` when the tap cannot be created.
Buffers arrive from the HAL as ``float32``; :meth:`read` hands them to the
caller as raw little-endian PCM bytes.
"""
def __init__(self) -> None:
self.sample_rate = 48000
self.channels = 2
self.bytes_per_frame = 8
self._tag = str(uuid.uuid4()).upper()
self._tap_id: int | None = None
self._device_id: int | None = None
self._io_proc: ctypes.c_void_p | None = None
self._callback: Any = None
self._buffers: deque[bytes] = deque()
self._buffered = 0
self._limit = int(self.sample_rate * self.bytes_per_frame * _BUFFER_SECONDS)
self._running = False
self._cond = threading.Condition()
# ----- lifecycle ---------------------------------------------------- #
def start(self) -> None:
if self._running:
return
if not available():
raise TapError("Core Audio process taps are unavailable on this system")
ca, cf, _ = _frameworks()
owned: list[int] = []
def cfstr(text: str) -> int:
ref = int(cf.CFStringCreateWithCString(None, text.encode(), _UTF8) or 0)
if not ref:
raise TapError("could not allocate a Core Foundation string")
owned.append(ref)
return ref
try:
description, nsuuid = self._describe(cfstr)
owned.extend((description, nsuuid))
tap_id = ctypes.c_uint32(0)
status = ca.AudioHardwareCreateProcessTap(description, ctypes.byref(tap_id))
if status != 0:
raise TapError(
f"AudioHardwareCreateProcessTap failed ({_osstatus(status)})"
)
self._tap_id = tap_id.value
self._read_format(ca)
uid = self._read_uid(ca)
if not uid:
raise TapError("could not read the tap UID")
owned.append(uid)
self._build_device(ca, cf, cfstr, uid, owned)
self._start_io(ca)
except Exception:
self._teardown()
raise
finally:
for ref in owned:
cf.CFRelease(ref)
with self._cond:
self._running = True
self._cond.notify_all()
def stop(self) -> None:
self._teardown()
with self._cond:
self._running = False
self._buffers.clear()
self._buffered = 0
self._cond.notify_all()
@property
def running(self) -> bool:
return self._running
# ----- capture ------------------------------------------------------ #
def read(self, timeout: float | None = 1.0) -> bytes | None:
"""Return the next buffer of PCM, or ``None`` on timeout / after stop."""
deadline = None if timeout is None else time.monotonic() + timeout
with self._cond:
while not self._buffers:
if not self._running:
return None
if deadline is None:
self._cond.wait(0.5)
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
self._cond.wait(remaining)
data = self._buffers.popleft()
self._buffered -= len(data)
return data
# ----- Core Audio plumbing ------------------------------------------ #
def _describe(self, cfstr) -> tuple[int, int]:
"""Build the ``CATapDescription`` for a global, unmuted stereo tap."""
objc = _frameworks()[2]
cls = objc.objc_getClass(b"CATapDescription")
if not cls:
raise TapError("CATapDescription is unavailable on this system")
# "…but exclude these processes": an empty list means everything the
# output device plays.
empty = _empty_array()
try:
description = _send_object(
_send_object(int(cls), b"alloc"),
b"initStereoGlobalTapButExcludeProcesses:",
empty,
)
finally:
# The new CATapDescription has copied the array.
_frameworks()[1].CFRelease(empty)
if not description:
raise TapError("could not create a CATapDescription")
nsuuid = _send_object(
_send_object(int(objc.objc_getClass(b"NSUUID")), b"alloc"),
b"initWithUUIDString:",
cfstr(self._tag),
)
if not nsuuid:
raise TapError("could not create the tap UUID")
_send_object(description, b"setUUID:", nsuuid)
_send_object(description, b"setName:", cfstr("beefy-player"))
_send_flag(description, b"setPrivate:", True)
# 0 == CATapUnmuted: the audio we record keeps playing as usual.
_send_integer(description, b"setMuteBehavior:", 0)
return description, nsuuid
def _read_uid(self, ca: ctypes.CDLL) -> int:
uid = ctypes.c_void_p(0)
size = ctypes.c_uint32(ctypes.sizeof(ctypes.c_void_p))
if _get_property(ca, self._tap_id, _TAP_UID, uid, size) != 0:
return 0
return int(uid.value or 0)
def _read_format(self, ca: ctypes.CDLL) -> None:
fmt = _StreamFormat()
size = ctypes.c_uint32(ctypes.sizeof(fmt))
if _get_property(ca, self._tap_id, _TAP_FORMAT, fmt, size) != 0:
raise TapError("could not read the tap's stream format")
if (
fmt.mFormatID != _FORMAT_LPCM
or fmt.mBitsPerChannel != 32
or not fmt.mFormatFlags & _FLAG_FLOAT
):
raise TapError(
"unsupported tap format "
f"({fmt.mFormatID.to_bytes(4, 'big')!r}, {fmt.mBitsPerChannel} bit)"
)
self.sample_rate = round(fmt.mSampleRate) or 48000
self.channels = int(fmt.mChannelsPerFrame) or 1
self.bytes_per_frame = int(fmt.mBytesPerFrame or 4 * self.channels)
self._limit = int(self.sample_rate * self.bytes_per_frame * _BUFFER_SECONDS)
def _build_device(
self, ca: ctypes.CDLL, cf: ctypes.CDLL, cfstr, uid: int, owned: list[int]
) -> None:
tap_entry = int(cf.CFDictionaryCreateMutable(None, 0, None, None) or 0)
if not tap_entry:
raise TapError("could not allocate the tap description dictionary")
owned.append(tap_entry)
cf.CFDictionarySetValue(tap_entry, cfstr(_SUB_TAP_UID), uid)
cf.CFDictionarySetValue(tap_entry, cfstr(_SUB_TAP_DRIFT), _boolean(True))
entries = (ctypes.c_void_p * 1)(tap_entry)
tap_list = int(cf.CFArrayCreate(None, entries, 1, None) or 0)
if not tap_list:
raise TapError("could not allocate the tap list")
owned.append(tap_list)
aggregate = int(cf.CFDictionaryCreateMutable(None, 0, None, None) or 0)
if not aggregate:
raise TapError("could not allocate the aggregate description")
owned.append(aggregate)
for key, value in (
(_AGGREGATE_UID, cfstr(str(uuid.uuid4()).upper())),
(_AGGREGATE_NAME, cfstr("beefy-player tap")),
(_AGGREGATE_PRIVATE, _boolean(True)),
(_AGGREGATE_STACKED, _boolean(False)),
(_AGGREGATE_TAPS, tap_list),
(_AGGREGATE_TAP_AUTOSTART, _boolean(True)),
):
cf.CFDictionarySetValue(aggregate, cfstr(key), value)
device_id = ctypes.c_uint32(0)
status = ca.AudioHardwareCreateAggregateDevice(
aggregate, ctypes.byref(device_id)
)
if status != 0:
raise TapError(
f"AudioHardwareCreateAggregateDevice failed ({_osstatus(status)})"
)
self._device_id = device_id.value
def _start_io(self, ca: ctypes.CDLL) -> None:
self._callback = _IOProc(self._on_audio)
io_proc = ctypes.c_void_p()
status = ca.AudioDeviceCreateIOProcID(
self._device_id, self._callback, None, ctypes.byref(io_proc)
)
if status != 0:
raise TapError(f"AudioDeviceCreateIOProcID failed ({_osstatus(status)})")
self._io_proc = io_proc
status = ca.AudioDeviceStart(self._device_id, io_proc)
if status != 0:
raise TapError(f"AudioDeviceStart failed ({_osstatus(status)})")
def _teardown(self) -> None:
device = self._device_id
io_proc = self._io_proc
tap = self._tap_id
self._device_id = None
self._io_proc = None
self._tap_id = None
self._callback = None
try:
ca = _frameworks()[0]
except OSError:
return
if device is not None:
if io_proc is not None:
ca.AudioDeviceStop(device, io_proc)
ca.AudioDeviceDestroyIOProcID(device, io_proc)
ca.AudioHardwareDestroyAggregateDevice(device)
if tap is not None:
ca.AudioHardwareDestroyProcessTap(tap)
# ----- real-time thread --------------------------------------------- #
def _on_audio(
self,
_device: int,
_now: int,
input_data: ctypes.POINTER(_AudioBufferList),
_input_time: int,
_output_data: int,
_output_time: int,
_client: int,
) -> int:
try:
chunk = _copy_buffers(input_data)
except Exception: # never let an exception escape into the HAL
return 0
if not chunk:
return 0
with self._cond:
self._buffers.append(chunk)
self._buffered += len(chunk)
while self._buffered > self._limit and self._buffers:
self._buffered -= len(self._buffers.popleft())
self._cond.notify_all()
return 0
def _empty_array() -> int:
cf = _frameworks()[1]
return int(cf.CFArrayCreate(None, None, 0, None) or 0)
def _boolean(value: bool) -> int:
cf = _frameworks()[1]
name = "kCFBooleanTrue" if value else "kCFBooleanFalse"
return int(ctypes.c_void_p.in_dll(cf, name).value or 0)
def _get_property(
ca: ctypes.CDLL,
obj: int | None,
selector: int,
target: Any,
size: ctypes.c_uint32,
) -> int:
if obj is None:
return -1
address = _PropertyAddress(selector, _SCOPE_GLOBAL, _ELEMENT_MAIN)
return ca.AudioObjectGetPropertyData(
obj, ctypes.byref(address), 0, None, ctypes.byref(size), ctypes.byref(target)
)
def _copy_buffers(input_data: ctypes.POINTER(_AudioBufferList)) -> bytes:
if not input_data:
return b""
block = input_data.contents
pieces = []
for index in range(min(int(block.mNumberBuffers), 8)):
buffer = block.mBuffers[index]
if buffer.mData and buffer.mDataByteSize:
pieces.append(ctypes.string_at(buffer.mData, buffer.mDataByteSize))
return b"".join(pieces)