Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public APIs may still change while the backend design stabilizes.

- Refactored native ring-buffer and stream-stat helpers without changing public
behavior.
- Added `hardware_latency` to stream statistics. macOS reports Core Audio
device latency plus safety offset; Linux reports `None` until a reliable
miniaudio latency value is available.

## [0.2.0a2] - 2026-06-10

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ Lifecycle semantics:

- `frames_processed`: frames consumed by the backend
- `underruns` / `overruns`: buffer starvation or rejected writes
- `hardware_latency`: backend-reported device latency in seconds when available
- `queued_frames`: frames currently waiting in the native ring
- `queued_latency`: queued ring duration in seconds
- `estimated_latency`: `queued_latency + hardware_latency` when hardware latency
is available, otherwise queued latency
- `buffer_size`: native callback buffer size in frames

## Development
Expand Down
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ captured float32 frames into a native ring buffer, and Python reads available
frames without invoking Python from the audio callback.

Stream statistics distinguish queue state from hardware behavior. `queued_frames`
and `queued_latency` describe the native ring buffer. `buffer_size` describes
the callback buffer size in frames. `estimated_latency` currently aliases queued
latency; future backends should include hardware output latency when available.
and `queued_latency` describe the native ring buffer. `hardware_latency`
describes backend-reported device latency when available. `buffer_size`
describes the callback buffer size in frames. `estimated_latency` reports
`queued_latency + hardware_latency` when hardware latency is available, otherwise
queued latency.

## Real-time constraints

Expand Down
166 changes: 163 additions & 3 deletions src/tachyaudio/_native.c
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ static size_t tachy_ring_copy_out_raw(
}

static PyObject *tachy_build_stream_stats(
unsigned long long frames_processed,
unsigned int underruns,
unsigned int overruns,
unsigned long long queued_frames,
double queued_latency,
double hardware_latency,
int has_hardware_latency,
unsigned int buffer_size)
{
double estimated_latency = queued_latency;
PyObject *hardware_latency_object = Py_None;
if (has_hardware_latency) {
estimated_latency += hardware_latency;
hardware_latency_object = PyFloat_FromDouble(hardware_latency);
if (hardware_latency_object == NULL) {
return NULL;
}
} else {
Py_INCREF(Py_None);
}

PyObject *stats = NULL;
stats = Py_BuildValue(
"{s:K,s:I,s:I,s:d,s:O,s:K,s:d,s:I}",
"frames_processed", frames_processed,
"underruns", underruns,
"overruns", overruns,
"estimated_latency", estimated_latency,
"hardware_latency", hardware_latency_object,
"queued_frames", queued_frames,
"queued_latency", queued_latency,
"buffer_size", buffer_size
);
Py_DECREF(hardware_latency_object);
return stats;
}

static PyObject *tachy_build_stream_stats_without_hardware_latency(
unsigned long long frames_processed,
unsigned int underruns,
unsigned int overruns,
Expand All @@ -58,11 +96,12 @@ static PyObject *tachy_build_stream_stats(
unsigned int buffer_size)
{
return Py_BuildValue(
"{s:K,s:I,s:I,s:d,s:K,s:d,s:I}",
"{s:K,s:I,s:I,s:d,s:O,s:K,s:d,s:I}",
"frames_processed", frames_processed,
"underruns", underruns,
"overruns", overruns,
"estimated_latency", queued_latency,
"hardware_latency", Py_None,
"queued_frames", queued_frames,
"queued_latency", queued_latency,
"buffer_size", buffer_size
Expand Down Expand Up @@ -95,6 +134,7 @@ typedef struct {
UInt32 pending_buffers;
UInt32 underruns;
UInt32 overruns;
double hardware_latency;
uint8_t *ring;
size_t ring_capacity;
size_t ring_read;
Expand All @@ -119,6 +159,7 @@ typedef struct {
UInt64 frames_processed;
UInt32 underruns;
UInt32 overruns;
double hardware_latency;
uint8_t *ring;
size_t ring_capacity;
size_t ring_read;
Expand All @@ -132,6 +173,13 @@ typedef struct {

static PyTypeObject TachyInputStreamType;

static AudioObjectID tachy_get_default_device(AudioObjectPropertySelector selector);
static AudioObjectID tachy_find_device_by_uid(const char *device_uid);
static double tachy_get_coreaudio_hardware_latency(
AudioObjectID device_id,
AudioObjectPropertyScope scope,
double sample_rate);

static void tachy_ring_copy_in(TachyOutputStream *stream, const uint8_t *source, size_t byte_count)
{
tachy_ring_copy_in_raw(
Expand Down Expand Up @@ -332,6 +380,7 @@ static PyObject *tachy_output_new(PyTypeObject *type, PyObject *args, PyObject *
self->pending_buffers = 0;
self->underruns = 0;
self->overruns = 0;
self->hardware_latency = 0.0;
self->ring = NULL;
self->ring_capacity = (size_t)sample_rate * TACHY_RING_SECONDS * self->bytes_per_frame;
if (self->ring_capacity < (size_t)self->buffer_byte_size * TACHY_OUTPUT_BUFFER_COUNT * 2) {
Expand Down Expand Up @@ -395,6 +444,14 @@ static PyObject *tachy_output_new(PyTypeObject *type, PyObject *args, PyObject *
return NULL;
}

AudioObjectID output_device = device_id == NULL || device_id[0] == '\0'
? tachy_get_default_device(kAudioHardwarePropertyDefaultOutputDevice)
: tachy_find_device_by_uid(device_id);
self->hardware_latency = tachy_get_coreaudio_hardware_latency(
output_device,
kAudioDevicePropertyScopeOutput,
(double)sample_rate);

for (UInt32 index = 0; index < TACHY_OUTPUT_BUFFER_COUNT; index++) {
status = AudioQueueAllocateBuffer(self->queue, self->buffer_byte_size, &self->buffers[index]);
if (status != noErr || self->buffers[index] == NULL) {
Expand Down Expand Up @@ -635,6 +692,8 @@ static PyObject *tachy_output_stats(TachyOutputStream *self, PyObject *Py_UNUSED
overruns,
queued_frames,
queued_latency,
self->hardware_latency,
1,
buffer_size);
}

Expand Down Expand Up @@ -757,6 +816,7 @@ static PyObject *tachy_input_new(PyTypeObject *type, PyObject *args, PyObject *k
self->frames_processed = 0;
self->underruns = 0;
self->overruns = 0;
self->hardware_latency = 0.0;
self->ring = NULL;
self->ring_capacity = (size_t)sample_rate * TACHY_RING_SECONDS * self->bytes_per_frame;
if (self->ring_capacity < (size_t)self->buffer_byte_size * TACHY_INPUT_BUFFER_COUNT * 2) {
Expand Down Expand Up @@ -819,6 +879,14 @@ static PyObject *tachy_input_new(PyTypeObject *type, PyObject *args, PyObject *k
return NULL;
}

AudioObjectID input_device = device_id == NULL || device_id[0] == '\0'
? tachy_get_default_device(kAudioHardwarePropertyDefaultInputDevice)
: tachy_find_device_by_uid(device_id);
self->hardware_latency = tachy_get_coreaudio_hardware_latency(
input_device,
kAudioDevicePropertyScopeInput,
(double)sample_rate);

for (UInt32 index = 0; index < TACHY_INPUT_BUFFER_COUNT; index++) {
status = AudioQueueAllocateBuffer(self->queue, self->buffer_byte_size, &self->buffers[index]);
if (status != noErr || self->buffers[index] == NULL) {
Expand Down Expand Up @@ -1000,6 +1068,8 @@ static PyObject *tachy_input_stats(TachyInputStream *self, PyObject *Py_UNUSED(i
overruns,
queued_frames,
queued_latency,
self->hardware_latency,
1,
buffer_size);
}

Expand Down Expand Up @@ -1115,6 +1185,96 @@ static AudioObjectID tachy_get_default_device(AudioObjectPropertySelector select
return device_id;
}

static AudioObjectID tachy_find_device_by_uid(const char *device_uid)
{
if (device_uid == NULL || device_uid[0] == '\0') {
return kAudioObjectUnknown;
}

AudioObjectPropertyAddress address = {
kAudioHardwarePropertyDevices,
kAudioObjectPropertyScopeGlobal,
kAudioObjectPropertyElementMain
};
UInt32 size = 0;
OSStatus status = AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, NULL, &size);

if (status != noErr || size == 0) {
return kAudioObjectUnknown;
}

AudioObjectID *device_ids = (AudioObjectID *)PyMem_RawMalloc(size);
if (device_ids == NULL) {
return kAudioObjectUnknown;
}

status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &size, device_ids);
if (status != noErr) {
PyMem_RawFree(device_ids);
return kAudioObjectUnknown;
}

AudioObjectID found_device = kAudioObjectUnknown;
UInt32 device_count = size / sizeof(AudioObjectID);
for (UInt32 index = 0; index < device_count; index++) {
char uid[256] = {0};
if (tachy_get_cf_string(device_ids[index], kAudioDevicePropertyDeviceUID, uid, sizeof(uid)) &&
strcmp(uid, device_uid) == 0) {
found_device = device_ids[index];
break;
}
}

PyMem_RawFree(device_ids);
return found_device;
}

static UInt32 tachy_get_coreaudio_uint32_property(
AudioObjectID device_id,
AudioObjectPropertySelector selector,
AudioObjectPropertyScope scope)
{
if (device_id == kAudioObjectUnknown) {
return 0;
}

AudioObjectPropertyAddress address = {
selector,
scope,
kAudioObjectPropertyElementMain
};
UInt32 value = 0;
UInt32 size = sizeof(value);
OSStatus status = AudioObjectGetPropertyData(device_id, &address, 0, NULL, &size, &value);

if (status != noErr) {
return 0;
}

return value;
}

static double tachy_get_coreaudio_hardware_latency(
AudioObjectID device_id,
AudioObjectPropertyScope scope,
double sample_rate)
{
if (sample_rate <= 0.0) {
return 0.0;
}

UInt32 latency_frames = tachy_get_coreaudio_uint32_property(
device_id,
kAudioDevicePropertyLatency,
scope);
UInt32 safety_offset_frames = tachy_get_coreaudio_uint32_property(
device_id,
kAudioDevicePropertySafetyOffset,
scope);

return (double)(latency_frames + safety_offset_frames) / sample_rate;
}

static int tachy_append_device(PyObject *devices, AudioObjectID device_id, const char *kind, UInt32 channels, int is_default)
{
char uid[256] = {0};
Expand Down Expand Up @@ -1688,7 +1848,7 @@ static PyObject *tachy_output_stats(TachyOutputStream *self, PyObject *Py_UNUSED
ma_uint32 buffer_size = self->buffer_frames;
pthread_mutex_unlock(&self->lock);

return tachy_build_stream_stats(
return tachy_build_stream_stats_without_hardware_latency(
frames_processed,
underruns,
overruns,
Expand Down Expand Up @@ -2085,7 +2245,7 @@ static PyObject *tachy_input_stats(TachyInputStream *self, PyObject *Py_UNUSED(i
ma_uint32 buffer_size = self->buffer_frames;
pthread_mutex_unlock(&self->lock);

return tachy_build_stream_stats(
return tachy_build_stream_stats_without_hardware_latency(
frames_processed,
underruns,
overruns,
Expand Down
2 changes: 2 additions & 0 deletions src/tachyaudio/_native_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def stats(self) -> object:
underruns=item["underruns"],
overruns=item["overruns"],
estimated_latency=item["estimated_latency"],
hardware_latency=item["hardware_latency"],
queued_frames=item["queued_frames"],
queued_latency=item["queued_latency"],
buffer_size=item["buffer_size"],
Expand Down Expand Up @@ -87,6 +88,7 @@ def stats(self) -> object:
underruns=item["underruns"],
overruns=item["overruns"],
estimated_latency=item["estimated_latency"],
hardware_latency=item["hardware_latency"],
queued_frames=item["queued_frames"],
queued_latency=item["queued_latency"],
buffer_size=item["buffer_size"],
Expand Down
3 changes: 3 additions & 0 deletions src/tachyaudio/_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class StreamStats:
underruns: int = 0
overruns: int = 0
estimated_latency: float | None = None
hardware_latency: float | None = None
queued_frames: int = 0
queued_latency: float = 0.0
buffer_size: int | None = None
Expand All @@ -54,6 +55,8 @@ def __post_init__(self) -> None:
raise ValueError("overruns cannot be negative")
if self.estimated_latency is not None and self.estimated_latency < 0:
raise ValueError("estimated_latency cannot be negative")
if self.hardware_latency is not None and self.hardware_latency < 0:
raise ValueError("hardware_latency cannot be negative")
if self.queued_frames < 0:
raise ValueError("queued_frames cannot be negative")
if self.queued_latency < 0:
Expand Down
10 changes: 9 additions & 1 deletion tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,19 @@ def test_stream_stats_preserves_zero_latency(self) -> None:
self.assertEqual(stats.estimated_latency, 0.0)

def test_stream_stats_validates_richer_fields(self) -> None:
stats = ta.StreamStats(queued_frames=12, queued_latency=0.25, buffer_size=256)
stats = ta.StreamStats(
hardware_latency=0.01,
queued_frames=12,
queued_latency=0.25,
buffer_size=256,
)
self.assertEqual(stats.hardware_latency, 0.01)
self.assertEqual(stats.queued_frames, 12)
self.assertEqual(stats.queued_latency, 0.25)
self.assertEqual(stats.buffer_size, 256)

with self.assertRaises(ValueError):
ta.StreamStats(hardware_latency=-0.1)
with self.assertRaises(ValueError):
ta.StreamStats(queued_frames=-1)
with self.assertRaises(ValueError):
Expand Down
Loading