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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ Official bindings wrap the C API for other languages:
| Swift / ObjC | [bindings/swift](bindings/swift) |

See [`docs/bindings.md`](docs/bindings.md) for how the bindings are generated
and kept in sync with the header.
and kept in sync with the header. Upgrading from 0.1? Read the
[0.2 migration guide](docs/migrating-to-0.2.md), including the new exact-device
selection API and the changed meaning of CLI `--device 0`.

## Tests

Expand Down
18 changes: 15 additions & 3 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ a C/C++ speech-to-text library built on ggml.
> **Status: in development.** Until wheels are published, use a locally built
> `libtranscribe` through repo auto-discovery or `TRANSCRIBE_LIBRARY`.

Upgrading from 0.1? See the
[0.2 migration guide](https://github.com/handy-computer/transcribe.cpp/blob/main/docs/migrating-to-0.2.md),
including the replacement of `gpu_device=` with exact device objects.

```python
import transcribe_cpp

Expand Down Expand Up @@ -44,9 +48,17 @@ Long transcriptions can be cancelled from another thread with

## Backends

`Model(backend=...)` picks the compute device (`"auto"` uses the best
available). `transcribe_cpp.backends()` lists registered backends and
`backend_available(kind)` checks one kind.
`Model(backend=...)` applies a backend policy (`"auto"` uses the best
available). `transcribe_cpp.backends()` returns process-local device objects;
pass one as `Model(device=device)` for exact selection with no fallback. Persist
a device's `device_id`, not its runtime handle or index. `backend_available(kind)`
checks whether a backend policy can currently be satisfied.

```python
device = next(d for d in transcribe_cpp.backends() if d.device_type == "cpu")
with transcribe_cpp.Model("model.gguf", device=device) as model:
print(model.device)
```

| Variable | Effect |
|---|---|
Expand Down
76 changes: 49 additions & 27 deletions bindings/python/src/transcribe_cpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import os
import threading
import weakref
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Literal, Optional, Sequence, Union

from . import _abi, _generated
Expand Down Expand Up @@ -266,9 +266,13 @@ def native_provider() -> str | None:
}


@dataclass(frozen=True)
@dataclass(frozen=True, eq=False)
class BackendDevice:
"""One registered compute device (owned copies of the C strings)."""
"""One registered compute device (owned copies of the C strings).

Equality compares opaque native identity; display index and live memory
snapshots do not affect whether two values name the same device.
"""

name: str
description: str
Expand All @@ -283,18 +287,26 @@ class BackendDevice:
# (via backends() or Model.device) to refresh; backend-defined and not
# comparable across device kinds.
memory_free: int
# Registry index of this device — the value to pass as ``Model(...,
# gpu_device=index)`` to select it (0 means auto: discrete GPUs are
# probed before integrated).
# None when the device came from Model.device, since the underlying
# transcribe_model_get_device() does not expose an index; correlate such a
# device back to backends() by device_id / name instead. The index is
# order-dependent and not stable across driver updates or hosts.
# Registry index for display. Exact model selection uses the BackendDevice
# itself; indices are process-local and not stable across driver updates.
index: Optional[int] = None
# Opaque process-local native device handle. Applications persist device_id,
# never this value.
_handle: Optional[int] = field(default=None, repr=False, compare=False)

def __eq__(self, other: object) -> bool:
if self is other:
return True
if not isinstance(other, BackendDevice):
return NotImplemented
return self._handle is not None and self._handle == other._handle

def __hash__(self) -> int:
return hash(self._handle) if self._handle is not None else object.__hash__(self)


def _backend_device_from_raw(dev, index: Optional[int] = None) -> BackendDevice:
"""Build a BackendDevice from a library-filled transcribe_backend_device."""
def _backend_device_from_raw(dev, handle: Optional[int], index: Optional[int] = None) -> BackendDevice:
"""Build a BackendDevice from a library-filled transcribe_device_info."""
return BackendDevice(
name=_decode(dev.name),
description=_decode(dev.description),
Expand All @@ -304,6 +316,7 @@ def _backend_device_from_raw(dev, index: Optional[int] = None) -> BackendDevice:
memory_total=int(dev.memory_total),
memory_free=int(dev.memory_free),
index=index,
_handle=handle,
)


Expand All @@ -315,12 +328,15 @@ def backends() -> list[BackendDevice]:
Each device's ``memory_free`` is live as of the call; call again to poll
a device's available memory over time."""
devices = []
for i in range(_lib.transcribe_backend_device_count()):
dev = _generated.transcribe_backend_device()
_lib.transcribe_backend_device_init(_byref(dev))
_check(_lib.transcribe_get_backend_device(i, _byref(dev)),
for i in range(_lib.transcribe_device_count()):
handle = _lib.transcribe_device_get(i)
if not handle:
continue
dev = _generated.transcribe_device_info()
_lib.transcribe_device_info_init(_byref(dev))
_check(_lib.transcribe_device_get_info(handle, _byref(dev)),
f"reading backend device {i}")
devices.append(_backend_device_from_raw(dev, index=i))
devices.append(_backend_device_from_raw(dev, int(handle), index=i))
return devices


Expand Down Expand Up @@ -857,7 +873,7 @@ class Model:
"""

def __init__(self, path: str | os.PathLike, *,
backend: Backend = "auto", gpu_device: int = 0):
backend: Backend = "auto", device: BackendDevice | None = None):
# Live sessions, tracked weakly: close() must free them before the
# model, because transcribe_model_free is only valid once every
# derived session is gone (use-after-free otherwise). Created before
Expand All @@ -870,7 +886,10 @@ def __init__(self, path: str | os.PathLike, *,
params = _ModelLoadParams()
_lib.transcribe_model_load_params_init(_byref(params))
params.backend = _enum(_BACKENDS, backend, backend_source)
params.gpu_device = gpu_device
if device is not None:
if not isinstance(device, BackendDevice) or device._handle is None:
raise TypeError("device must be a BackendDevice returned by backends()")
params.device = device._handle

handle = ctypes.c_void_p()
status = _lib.transcribe_model_load_file(
Expand Down Expand Up @@ -905,11 +924,14 @@ def device(self) -> BackendDevice:
live snapshot, so read this again to poll how much device memory is
left after the model loaded. Raises if the model has no resolved
compute device."""
dev = _generated.transcribe_backend_device()
_lib.transcribe_backend_device_init(_byref(dev))
_check(_lib.transcribe_model_get_device(self._h, _byref(dev)),
"model_get_device")
return _backend_device_from_raw(dev)
handle = _lib.transcribe_model_device(self._h)
if not handle:
raise BackendError("model has no resolved compute device")
dev = _generated.transcribe_device_info()
_lib.transcribe_device_info_init(_byref(dev))
_check(_lib.transcribe_device_get_info(handle, _byref(dev)),
"device_get_info")
return _backend_device_from_raw(dev, int(handle))

@property
def capabilities(self) -> Capabilities:
Expand Down Expand Up @@ -1431,7 +1453,7 @@ def transcribe(
pcm: PCMLike,
*,
backend: Backend = "auto",
gpu_device: int = 0,
device: BackendDevice | None = None,
n_threads: int = 0,
kv_type: KVType = "auto",
n_ctx: int = 0,
Expand All @@ -1449,7 +1471,7 @@ def transcribe(
*model* may be a path (loaded and freed within this call) or an existing
Model (reused and left open). Loading a model is not free, so to transcribe
many clips keep a Model and call ``model.session().run(...)`` yourself; this
helper is for the one-shot case. ``backend`` / ``gpu_device`` apply only when
helper is for the one-shot case. ``backend`` / ``device`` apply only when
*model* is a path — they are ignored when an already-loaded Model is passed.
``family`` / ``spec_k_drafts`` pass through to :meth:`Session.run`.
"""
Expand All @@ -1463,6 +1485,6 @@ def transcribe(
with model.session(**session_opts) as session:
return session.run(pcm, **run_opts)

with Model(model, backend=backend, gpu_device=gpu_device) as owned:
with Model(model, backend=backend, device=device) as owned:
with owned.session(**session_opts) as session:
return session.run(pcm, **run_opts)
34 changes: 18 additions & 16 deletions bindings/python/src/transcribe_cpp/_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Stable digest of the ABI surface below (structs, enums, macros, layout,
# prototypes). A native provider package echoes this back so the API
# package can reject an ABI-mismatched provider before dlopen.
PUBLIC_HEADER_HASH = "7896d8d4c2a46147"
PUBLIC_HEADER_HASH = "7df72bf9e667b8c2"

# === enum constants ===
TRANSCRIBE_OK = 0
Expand Down Expand Up @@ -48,7 +48,7 @@
TRANSCRIBE_ABI_STREAM_TEXT = 10
TRANSCRIBE_ABI_SESSION_LIMITS = 11
TRANSCRIBE_ABI_EXT = 12
TRANSCRIBE_ABI_BACKEND_DEVICE = 13
TRANSCRIBE_ABI_DEVICE_INFO = 13
TRANSCRIBE_ABI_SPEAKER_SEGMENT = 14
TRANSCRIBE_LOG_LEVEL_NONE = 0
TRANSCRIBE_LOG_LEVEL_INFO = 1
Expand Down Expand Up @@ -120,7 +120,7 @@
# === structs ===
class transcribe_ext(_c.Structure):
pass
class transcribe_backend_device(_c.Structure):
class transcribe_device_info(_c.Structure):
pass
class transcribe_model_load_params(_c.Structure):
pass
Expand Down Expand Up @@ -164,8 +164,8 @@ class transcribe_whisper_chunk_trace(_c.Structure):
pass

transcribe_ext._fields_ = [("size", _c.c_uint64), ("kind", _c.c_uint32)]
transcribe_backend_device._fields_ = [("struct_size", _c.c_uint64), ("name", _c.c_char_p), ("description", _c.c_char_p), ("kind", _c.c_char_p), ("device_id", _c.c_char_p), ("memory_total", _c.c_uint64), ("memory_free", _c.c_uint64), ("device_type", _c.c_int)]
transcribe_model_load_params._fields_ = [("struct_size", _c.c_uint64), ("backend", _c.c_int), ("gpu_device", _c.c_int)]
transcribe_device_info._fields_ = [("struct_size", _c.c_uint64), ("name", _c.c_char_p), ("description", _c.c_char_p), ("kind", _c.c_char_p), ("device_id", _c.c_char_p), ("memory_total", _c.c_uint64), ("memory_free", _c.c_uint64), ("device_type", _c.c_int)]
transcribe_model_load_params._fields_ = [("struct_size", _c.c_uint64), ("backend", _c.c_int), ("device", _c.c_void_p)]
transcribe_session_params._fields_ = [("struct_size", _c.c_uint64), ("n_threads", _c.c_int), ("kv_type", _c.c_int), ("n_ctx", _c.c_int32)]
transcribe_run_params._fields_ = [("struct_size", _c.c_uint64), ("task", _c.c_int), ("timestamps", _c.c_int), ("pnc", _c.c_int), ("itn", _c.c_int), ("diarize", _c.c_int), ("language", _c.c_char_p), ("target_language", _c.c_char_p), ("keep_special_tags", _c.c_bool), ("family", _c.POINTER(transcribe_ext)), ("spec_k_drafts", _c.c_int32)]
transcribe_capabilities._fields_ = [("struct_size", _c.c_uint64), ("native_sample_rate", _c.c_int32), ("n_languages", _c.c_int), ("languages", _c.POINTER(_c.c_char_p)), ("max_timestamp_kind", _c.c_int), ("supports_language_detect", _c.c_bool), ("supports_translate", _c.c_bool), ("supports_streaming", _c.c_bool), ("supports_spec_decode", _c.c_bool), ("max_audio_ms", _c.c_int64), ("n_translate_target_languages", _c.c_int), ("translate_target_languages", _c.POINTER(_c.c_char_p))]
Expand All @@ -190,7 +190,7 @@ class transcribe_whisper_chunk_trace(_c.Structure):
# transcribe_abi_struct id per struct (for the native size/align check).
ABI_STRUCT_IDS = {
'transcribe_ext': 12,
'transcribe_backend_device': 13,
'transcribe_device_info': 13,
'transcribe_model_load_params': 0,
'transcribe_session_params': 1,
'transcribe_run_params': 2,
Expand All @@ -209,8 +209,8 @@ class transcribe_whisper_chunk_trace(_c.Structure):
# C-compiler layout captured at generation (for offset self-check).
STRUCT_LAYOUT = {
'transcribe_ext': {'size': 16, 'align': 8, 'offsets': {'size': 0, 'kind': 8}},
'transcribe_backend_device': {'size': 64, 'align': 8, 'offsets': {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24, 'device_id': 32, 'memory_total': 40, 'memory_free': 48, 'device_type': 56}},
'transcribe_model_load_params': {'size': 16, 'align': 8, 'offsets': {'struct_size': 0, 'backend': 8, 'gpu_device': 12}},
'transcribe_device_info': {'size': 64, 'align': 8, 'offsets': {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24, 'device_id': 32, 'memory_total': 40, 'memory_free': 48, 'device_type': 56}},
'transcribe_model_load_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'backend': 8, 'device': 16}},
'transcribe_session_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'n_threads': 8, 'kv_type': 12, 'n_ctx': 16}},
'transcribe_run_params': {'size': 72, 'align': 8, 'offsets': {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'diarize': 24, 'language': 32, 'target_language': 40, 'keep_special_tags': 48, 'family': 56, 'spec_k_drafts': 64}},
'transcribe_capabilities': {'size': 56, 'align': 8, 'offsets': {'struct_size': 0, 'native_sample_rate': 8, 'n_languages': 12, 'languages': 16, 'max_timestamp_kind': 24, 'supports_language_detect': 28, 'supports_translate': 29, 'supports_streaming': 30, 'supports_spec_decode': 31, 'max_audio_ms': 32, 'n_translate_target_languages': 40, 'translate_target_languages': 48}},
Expand Down Expand Up @@ -241,10 +241,6 @@ def configure(lib):
lib.transcribe_abi_struct_size.argtypes = [_c.c_int]
lib.transcribe_backend_available.restype = _c.c_bool
lib.transcribe_backend_available.argtypes = [_c.c_int]
lib.transcribe_backend_device_count.restype = _c.c_int
lib.transcribe_backend_device_count.argtypes = []
lib.transcribe_backend_device_init.restype = None
lib.transcribe_backend_device_init.argtypes = [_c.POINTER(transcribe_backend_device)]
lib.transcribe_batch_detected_language.restype = _c.c_char_p
lib.transcribe_batch_detected_language.argtypes = [_c.c_void_p, _c.c_int]
lib.transcribe_batch_full_text.restype = _c.c_char_p
Expand Down Expand Up @@ -281,12 +277,18 @@ def configure(lib):
lib.transcribe_close.argtypes = [_c.c_void_p]
lib.transcribe_detected_language.restype = _c.c_char_p
lib.transcribe_detected_language.argtypes = [_c.c_void_p]
lib.transcribe_device_count.restype = _c.c_int
lib.transcribe_device_count.argtypes = []
lib.transcribe_device_get.restype = _c.c_void_p
lib.transcribe_device_get.argtypes = [_c.c_int]
lib.transcribe_device_get_info.restype = _c.c_int
lib.transcribe_device_get_info.argtypes = [_c.c_void_p, _c.POINTER(transcribe_device_info)]
lib.transcribe_device_info_init.restype = None
lib.transcribe_device_info_init.argtypes = [_c.POINTER(transcribe_device_info)]
lib.transcribe_ext_check.restype = _c.c_int
lib.transcribe_ext_check.argtypes = [_c.POINTER(transcribe_ext), _c.c_uint32, _c.c_uint64]
lib.transcribe_full_text.restype = _c.c_char_p
lib.transcribe_full_text.argtypes = [_c.c_void_p]
lib.transcribe_get_backend_device.restype = _c.c_int
lib.transcribe_get_backend_device.argtypes = [_c.c_int, _c.POINTER(transcribe_backend_device)]
lib.transcribe_get_model.restype = _c.c_void_p
lib.transcribe_get_model.argtypes = [_c.c_void_p]
lib.transcribe_get_segment.restype = _c.c_int
Expand Down Expand Up @@ -315,12 +317,12 @@ def configure(lib):
lib.transcribe_model_arch_string.argtypes = [_c.c_void_p]
lib.transcribe_model_backend.restype = _c.c_char_p
lib.transcribe_model_backend.argtypes = [_c.c_void_p]
lib.transcribe_model_device.restype = _c.c_void_p
lib.transcribe_model_device.argtypes = [_c.c_void_p]
lib.transcribe_model_free.restype = None
lib.transcribe_model_free.argtypes = [_c.c_void_p]
lib.transcribe_model_get_capabilities.restype = _c.c_int
lib.transcribe_model_get_capabilities.argtypes = [_c.c_void_p, _c.POINTER(transcribe_capabilities)]
lib.transcribe_model_get_device.restype = _c.c_int
lib.transcribe_model_get_device.argtypes = [_c.c_void_p, _c.POINTER(transcribe_backend_device)]
lib.transcribe_model_load_file.restype = _c.c_int
lib.transcribe_model_load_file.argtypes = [_c.c_char_p, _c.POINTER(transcribe_model_load_params), _c.POINTER(_c.c_void_p)]
lib.transcribe_model_load_params_init.restype = None
Expand Down
17 changes: 13 additions & 4 deletions bindings/python/tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

from dataclasses import replace

import pytest

import transcribe_cpp as t
Expand All @@ -31,8 +33,8 @@ def test_backends_non_empty():


def test_device_index_and_fields():
# Each device carries its registry index (the value Model(..., gpu_device=)
# selects with) and well-formed metadata. Pin the device-selection surface.
# Each device carries a process-local display index and an opaque selection
# handle, plus well-formed metadata. Pin the device-selection surface.
devices = t.backends()
for i, dev in enumerate(devices):
assert dev.index == i, f"device {i} reported index {dev.index}"
Expand All @@ -46,6 +48,13 @@ def test_device_index_and_fields():
assert isinstance(dev.kind, str) and dev.kind


def test_device_equality_uses_native_identity():
device = t.backends()[0]
refreshed = replace(device, memory_free=device.memory_free + 1, index=None)
assert refreshed == device
assert hash(refreshed) == hash(device)


def test_cpu_always_available():
# Every shipped configuration includes a CPU backend (compiled in or as
# the baseline module); a process without one is mispackaged.
Expand Down Expand Up @@ -93,6 +102,6 @@ def test_init_backends_rejects_bad_dirs():
def test_init_backends_idempotent():
lib = t._lib
adir = str(_library.artifact_dir()).encode("utf-8")
n = lib.transcribe_backend_device_count()
n = lib.transcribe_device_count()
assert lib.transcribe_init_backends(adir) == 0
assert lib.transcribe_backend_device_count() == n # no re-registration
assert lib.transcribe_device_count() == n # no re-registration
Loading
Loading