Owned backend handles¶
pyalsoft.bindings has two complementary low-level interfaces:
library.alandlibrary.alcare generated, function-oriented wrappers for every command in the vendored OpenAL registry.- The owned handle API adds deterministic device and context lifetimes without hiding the underlying native pointers.
The generated API remains available for applications that already manage ALC lifetimes themselves. The hand-written classes and functions described here are listed in the owned backend API reference.
Playback contexts¶
open_device returns an owned playback device. Contexts created from it are
closed before the device, including when an exception leaves a with block:
from pyalsoft import bindings
with bindings.open_device() as device:
with device.create_context() as context:
with context.activate():
print(context.vendor, context.renderer, context.version)
print(device.name, device.version)
buffer_ids = context.library.al.gen_buffers()
buffer = context.buffer(buffer_ids[0])
print(buffer.frequency)
Context.activate() restores the previous process-wide context. Pass
thread_local=True to use ALC_EXT_thread_local_context; the extension is
checked before changing thread state.
Typed Source, Buffer, Effect, Filter, AuxiliaryEffectSlot, and
Listener values are created through Context and retain that context's
identity. Their property descriptors safely activate the owning context and
reject objects from another context. The generated library.al namespace
continues to expose raw integer commands for applications that deliberately
manage current-context state themselves.
The native pointers are available as device.handle and context.handle for
generated commands that do not yet have a convenience method. Accessing either
handle after closure raises HandleClosedError.
Context and device state¶
The handle properties cover state that the registry can describe but cannot attach to generated AL object descriptors. Examples include:
- context identity, extensions, Doppler settings, speed of sound, distance model, and default filter order;
- device identity, ALC version, connection state, HRTF state and specifiers, output limiter and mode, device clock and latency, ambisonic order, and debug context flags.
Extension-backed properties raise ExtensionUnavailableError when queried on
an implementation that does not provide the required extension. Unknown future
enum values are returned as integers rather than causing a conversion failure.
Extension entry points are cached by the resolver and native scope that
produced them. AL functions are scoped to the effective current context;
ALC functions are scoped to the supplied device (or the null-device scope).
Owned contexts and devices invalidate their entries when closed. Applications
that destroy or reconfigure contexts and devices through the raw generated API
should call library.clear_extension_cache() before a native handle address can
be reused.
Loopback rendering¶
open_loopback_device provides deterministic, device-independent rendering
through ALC_SOFT_loopback:
from pyalsoft import bindings
attributes = [
bindings.ALC_FORMAT_CHANNELS_SOFT,
bindings.ALC_STEREO_SOFT,
bindings.ALC_FORMAT_TYPE_SOFT,
bindings.ALC_SHORT_SOFT,
bindings.ALC_FREQUENCY,
48_000,
]
with bindings.open_loopback_device() as device:
with device.create_context(attributes) as context:
with context.activate():
output = bytearray(512 * 2 * 2)
# Start sources before rendering.
device.render_samples(output, 512)
The wheel test suite runs tools/conformance_test_runtime.py against this path
to exercise real core playback, typed object properties, EFX, extension
resolution, and debug callback marshalling without requiring audio hardware.
Capture devices¶
Core ALC capture devices use a distinct owned type and matching close command:
from pyalsoft import bindings
with bindings.open_capture_device(
48_000,
bindings.AL_FORMAT_MONO16,
4_800,
) as capture:
capture.start()
available = capture.available_samples
samples = bytearray(available * 2)
capture.read_samples(samples, available)
The caller owns capture and loopback output storage. Pass a writable
bytearray, writable memoryview, ctypes allocation, or another object accepted
by the generated command wrapper.
Native callbacks¶
Context.register_event_callback and Context.register_debug_callback retain
their ctypes trampolines until the returned CallbackRegistration is closed.
They also prevent Python exceptions from crossing a native callback boundary:
with context.register_debug_callback(
lambda source, type, id, severity, message: print(message)
) as registration:
# Run OpenAL operations that may produce debug messages.
...
registration.raise_if_failed()
Callback exceptions are available through registration.errors and can be
re-raised as a BaseExceptionGroup in an application-controlled thread.
Closing a registration first unregisters it, then waits for any in-flight
invocations before releasing the ctypes trampoline. A callback must not close
its own registration; that attempt is rejected and retained as a callback
error.
System audio-device events are global to the loaded OpenAL implementation, so
their registration is owned by OpenALLibrary rather than a device:
with library.register_system_event_callback(
lambda event, device_type, device, message: print(message),
event_types=[bindings.ALC_EVENT_TYPE_DEVICE_ADDED_SOFT],
) as registration:
...
Only one system-event callback can be active for a native OpenAL library.
Replacing it closes the previous registration. The callback may run on a
background system thread; it should enqueue minimal immutable data and return.
It must not call AL or ALC functions. Call
library.clear_system_event_callback() to unregister it even when the returned
registration was not retained.
Context.register_buffer_callback owns a callback for one buffer. It accepts
either an integer identifier or a typed Buffer; typed buffers must come from
the same OpenALLibrary. It passes a writable memoryview that is valid only
until the callback returns, and the callback returns the number of bytes
written. Exceptions and invalid return sizes are retained on the registration
and reported to OpenAL as zero bytes written:
def fill(samples: memoryview) -> int:
samples[:] = next_audio_block(len(samples))
return len(samples)
with context.register_buffer_callback(
buffer_id,
bindings.AL_FORMAT_MONO16,
48_000,
fill,
) as registration:
...
This API makes the Python/native lifetime and exception boundary safe; it does
not make Python execution hard real-time safe. AL_SOFT_callback_buffer
callbacks run in the audio mixing path and must avoid blocking, I/O, allocation,
and unbounded work. A native callback remains the appropriate choice when hard
real-time behavior is required.
Explicitly closing a buffer callback asks OpenAL to replace its callback-backed storage and can fail while the buffer remains attached to a source. Context teardown handles this case safely by retaining the trampoline until the native context, sources, and buffers have been destroyed.
Context.start_foldback retains both the foldback callback and the exact
writable ALfloat backing allocation. The registration exposes that allocation
as registration.memory, including after closure so captured samples remain
available. It validates the extension's supported mono/stereo modes, minimum
count, and minimum storage capacity. Closing it requests a stop and waits for
OpenAL's STOP event before releasing the native callback. Foldback callbacks
must return promptly and must not call AL/ALC functions or close their own
registration.
Retained static buffers¶
AL_EXT_STATIC_BUFFER stores the supplied pointer instead of copying its
contents. Use Context.set_static_buffer_data with a same-library typed
Buffer or integer identifier and bytes, bytearray, or a contiguous
memoryview so the context retains the exact native backing:
Writable buffers are borrowed and pinned against resizing; read-only buffers
receive a retained native copy. The storage is released after the context is
destroyed. Raw callback, foldback, direct-context, and static-buffer commands
remain available through the generated namespaces, but callers using those
commands own every associated callback and backing-storage lifetime. Generated
static-buffer commands borrow the exact address of a writable buffer or ctypes
allocation; keep that storage alive and do not resize it while OpenAL may use
the buffer. Use Context.set_static_buffer_data when the binding should manage
that lifetime or when the input is immutable.