277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""ctypes bridge for the dependency-free MSVC C++ JOC DSP core.
|
|
|
|
Metadata parsing intentionally stays in Python. One C call consumes a complete
|
|
1536-sample frame, so Python is not involved in the hot 24-timeslot x 15-object
|
|
DSP loops.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
from evo_unpack import unpack_evolution
|
|
from joc_decode import dequantize, diff_decode, parse_joc
|
|
|
|
FRAME_SAMPLES = 1536
|
|
MAX_OBJECTS = 15
|
|
MAX_DPOINTS = 2
|
|
CORE_CHANNELS = 5
|
|
MAX_BANDS = 23
|
|
ABI_VERSION = 1
|
|
|
|
|
|
class NativeBackendUnavailable(RuntimeError):
|
|
pass
|
|
|
|
|
|
def native_library_filename():
|
|
if sys.platform == "win32":
|
|
return "eac3joc_core.dll"
|
|
if sys.platform == "darwin":
|
|
return "libeac3joc_core.dylib"
|
|
if sys.platform.startswith("linux"):
|
|
return "libeac3joc_core.so"
|
|
raise NativeBackendUnavailable(f"unsupported native platform: {sys.platform}")
|
|
|
|
|
|
def _candidate_libraries():
|
|
override = os.environ.get("EAC3JOC_NATIVE_LIBRARY")
|
|
if override:
|
|
yield Path(override).expanduser()
|
|
root = Path(__file__).resolve().parent.parent
|
|
yield root / "lib" / native_library_filename()
|
|
|
|
|
|
def find_native_library(explicit=None):
|
|
if explicit is not None:
|
|
path = Path(explicit).expanduser().resolve()
|
|
if not path.is_file():
|
|
raise NativeBackendUnavailable(f"native library not found: {path}")
|
|
return path
|
|
checked = []
|
|
for item in _candidate_libraries():
|
|
path = item.resolve()
|
|
checked.append(str(path))
|
|
if path.is_file():
|
|
return path
|
|
raise NativeBackendUnavailable("native library not found; checked: " + "; ".join(checked))
|
|
|
|
|
|
def default_native_threads():
|
|
override = os.environ.get("EAC3JOC_NATIVE_THREADS")
|
|
if override is not None:
|
|
value = int(override)
|
|
if value < 1:
|
|
raise ValueError("EAC3JOC_NATIVE_THREADS must be at least 1")
|
|
return min(value, MAX_OBJECTS)
|
|
return 2 if (os.cpu_count() or 1) >= 4 else 1
|
|
|
|
def _load_library(path):
|
|
lib = ctypes.CDLL(str(path))
|
|
float_p = ctypes.POINTER(ctypes.c_float)
|
|
u8_p = ctypes.POINTER(ctypes.c_uint8)
|
|
double_p = ctypes.POINTER(ctypes.c_double)
|
|
|
|
lib.ejoc_abi_version.argtypes = []
|
|
lib.ejoc_abi_version.restype = ctypes.c_uint32
|
|
lib.ejoc_build_info.argtypes = []
|
|
lib.ejoc_build_info.restype = ctypes.c_char_p
|
|
lib.ejoc_renderer_create.argtypes = []
|
|
lib.ejoc_renderer_create.restype = ctypes.c_void_p
|
|
lib.ejoc_renderer_destroy.argtypes = [ctypes.c_void_p]
|
|
lib.ejoc_renderer_destroy.restype = None
|
|
lib.ejoc_renderer_reset.argtypes = [ctypes.c_void_p]
|
|
lib.ejoc_renderer_reset.restype = ctypes.c_int
|
|
lib.ejoc_renderer_set_threads.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
|
lib.ejoc_renderer_set_threads.restype = ctypes.c_int
|
|
lib.ejoc_renderer_thread_count.argtypes = [ctypes.c_void_p]
|
|
lib.ejoc_renderer_thread_count.restype = ctypes.c_uint32
|
|
lib.ejoc_renderer_last_error.argtypes = [ctypes.c_void_p]
|
|
lib.ejoc_renderer_last_error.restype = ctypes.c_char_p
|
|
lib.ejoc_renderer_process.argtypes = [
|
|
ctypes.c_void_p,
|
|
float_p,
|
|
float_p,
|
|
ctypes.c_uint32,
|
|
u8_p,
|
|
u8_p,
|
|
u8_p,
|
|
u8_p,
|
|
double_p,
|
|
ctypes.c_double,
|
|
ctypes.c_float,
|
|
ctypes.c_float,
|
|
float_p,
|
|
]
|
|
lib.ejoc_renderer_process.restype = ctypes.c_int
|
|
abi = int(lib.ejoc_abi_version())
|
|
if abi != ABI_VERSION:
|
|
raise NativeBackendUnavailable(f"native ABI mismatch: library={abi}, Python={ABI_VERSION}")
|
|
return lib
|
|
|
|
|
|
class NativeJocRenderer:
|
|
"""Stateful whole-frame native DSP renderer with the Python renderer API shape."""
|
|
|
|
def __init__(self, output_scale=1.0, library_path=None, threads=None):
|
|
self.output_scale = np.float32(output_scale)
|
|
if not np.isfinite(self.output_scale):
|
|
raise ValueError("output_scale must be finite")
|
|
self.library_path = find_native_library(library_path)
|
|
self._lib = _load_library(self.library_path)
|
|
self._handle = self._lib.ejoc_renderer_create()
|
|
if not self._handle:
|
|
raise MemoryError("ejoc_renderer_create failed")
|
|
requested_threads = default_native_threads() if threads is None else int(threads)
|
|
if requested_threads < 1:
|
|
raise ValueError("threads must be at least 1")
|
|
result = self._lib.ejoc_renderer_set_threads(self._handle, requested_threads)
|
|
if result:
|
|
self._raise_native("set_threads", result)
|
|
self.threads = int(self._lib.ejoc_renderer_thread_count(self._handle))
|
|
self._n_bands = np.zeros(MAX_OBJECTS, dtype=np.uint8)
|
|
self._n_dpoints = np.zeros(MAX_OBJECTS, dtype=np.uint8)
|
|
self._slope_idx = np.zeros(MAX_OBJECTS, dtype=np.uint8)
|
|
self._offset_ts = np.zeros((MAX_OBJECTS, MAX_DPOINTS), dtype=np.uint8)
|
|
self._dq = np.zeros(
|
|
(MAX_OBJECTS, MAX_DPOINTS, CORE_CHANNELS, MAX_BANDS),
|
|
dtype=np.float64,
|
|
)
|
|
self._output = np.zeros((16, FRAME_SAMPLES), dtype=np.float32)
|
|
|
|
@property
|
|
def build_info(self):
|
|
value = self._lib.ejoc_build_info()
|
|
return value.decode("utf-8", "replace") if value else ""
|
|
|
|
@staticmethod
|
|
def decode_payload(payload_bytes):
|
|
subs, _ = unpack_evolution(payload_bytes, loose=True)
|
|
return NativeJocRenderer.decode_subpayloads(subs)
|
|
|
|
@staticmethod
|
|
def decode_subpayloads(subs):
|
|
if 14 not in subs:
|
|
raise ValueError("EMDF missing ID14/JOC")
|
|
out = parse_joc(subs[14])
|
|
mix_q = diff_decode(out)
|
|
mix_dq = dequantize(out, mix_q)
|
|
return out, mix_q, mix_dq
|
|
|
|
def reset(self):
|
|
self._require_open()
|
|
result = self._lib.ejoc_renderer_reset(self._handle)
|
|
if result:
|
|
self._raise_native("reset", result)
|
|
|
|
def close(self):
|
|
handle = getattr(self, "_handle", None)
|
|
if handle:
|
|
self._lib.ejoc_renderer_destroy(handle)
|
|
self._handle = None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
self.close()
|
|
|
|
def __del__(self):
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def _require_open(self):
|
|
if not self._handle:
|
|
raise RuntimeError("native renderer is closed")
|
|
|
|
def _raise_native(self, operation, code):
|
|
raw = self._lib.ejoc_renderer_last_error(self._handle)
|
|
detail = raw.decode("utf-8", "replace") if raw else "unknown native error"
|
|
raise RuntimeError(f"native {operation} failed ({code}): {detail}")
|
|
|
|
def _pack_frame(self, out, mix_dq):
|
|
if out["n_channels"] != CORE_CHANNELS:
|
|
raise ValueError(f"native core requires 5 JOC channels, got {out['n_channels']}")
|
|
if out["n_objects"] > MAX_OBJECTS:
|
|
raise ValueError(f"native core supports at most 15 objects, got {out['n_objects']}")
|
|
self._n_bands.fill(0)
|
|
self._n_dpoints.fill(0)
|
|
self._slope_idx.fill(0)
|
|
self._offset_ts.fill(0)
|
|
mask = 0
|
|
for object_index, info in enumerate(out["objs"]):
|
|
if not info["present"]:
|
|
continue
|
|
if info["sparse"]:
|
|
raise ValueError("native core does not accept unvalidated Sparse JOC")
|
|
bands = int(info["n_bands"])
|
|
points = int(info["n_dpoints"])
|
|
if bands > MAX_BANDS or points > MAX_DPOINTS:
|
|
raise ValueError(f"native descriptor out of range: bands={bands}, points={points}")
|
|
values = np.asarray(mix_dq[object_index], dtype=np.float64)
|
|
expected = (points, CORE_CHANNELS, bands)
|
|
if values.shape != expected:
|
|
raise ValueError(f"object {object_index} dq shape {values.shape}, expected {expected}")
|
|
mask |= 1 << object_index
|
|
self._n_bands[object_index] = bands
|
|
self._n_dpoints[object_index] = points
|
|
self._slope_idx[object_index] = int(info["slope_idx"])
|
|
offsets = info.get("offset_ts", ())
|
|
self._offset_ts[object_index, :len(offsets)] = offsets
|
|
self._dq[object_index, :points, :, :bands] = values
|
|
return mask
|
|
|
|
def render_frame(self, payload_bytes, bed5_pcm, lfe_pcm=None):
|
|
subs, _ = unpack_evolution(payload_bytes, loose=True)
|
|
return self.render_subpayloads(subs, bed5_pcm, lfe_pcm)
|
|
|
|
def render_subpayloads(self, subs, bed5_pcm, lfe_pcm=None):
|
|
self._require_open()
|
|
out, _, mix_dq = self.decode_subpayloads(subs)
|
|
object_mask = self._pack_frame(out, mix_dq)
|
|
bed5 = np.ascontiguousarray(bed5_pcm, dtype=np.float32)
|
|
if bed5.shape != (CORE_CHANNELS, FRAME_SAMPLES):
|
|
raise ValueError(f"core PCM shape must be (5,1536), got {bed5.shape}")
|
|
if lfe_pcm is None:
|
|
lfe = None
|
|
lfe_ptr = ctypes.POINTER(ctypes.c_float)()
|
|
else:
|
|
lfe = np.ascontiguousarray(lfe_pcm, dtype=np.float32)
|
|
if lfe.shape != (FRAME_SAMPLES,):
|
|
raise ValueError(f"LFE shape must be (1536,), got {lfe.shape}")
|
|
lfe_ptr = lfe.ctypes.data_as(ctypes.POINTER(ctypes.c_float))
|
|
|
|
result = self._lib.ejoc_renderer_process(
|
|
self._handle,
|
|
bed5.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
lfe_ptr,
|
|
object_mask,
|
|
self._n_bands.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)),
|
|
self._n_dpoints.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)),
|
|
self._slope_idx.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)),
|
|
self._offset_ts.ctypes.data_as(ctypes.POINTER(ctypes.c_uint8)),
|
|
self._dq.ctypes.data_as(ctypes.POINTER(ctypes.c_double)),
|
|
float(out["clipgain"]),
|
|
ctypes.c_float(0.0625),
|
|
ctypes.c_float(self.output_scale),
|
|
self._output.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
)
|
|
if result:
|
|
self._raise_native("process", result)
|
|
return self._output, None
|
|
|
|
|
|
def native_available(library_path=None):
|
|
try:
|
|
path = find_native_library(library_path)
|
|
lib = _load_library(path)
|
|
return True, str(path), (lib.ejoc_build_info() or b"").decode("utf-8", "replace")
|
|
except Exception as exc:
|
|
return False, None, str(exc)
|