move voicemeetertype(), voicemeeterversion() and samplerate() properties into VbanPacket

add NamedTuples for Levels, Labels and States.

refactor the levels properties

update the math in util.comp()

StripLevel/BusLevel getters updated according to changes in VbanPacketNBS0

remove {VbanCmd}._get_levels(), it's no longer necessary.
This commit is contained in:
onyx-and-iris 2026-03-01 00:25:22 +00:00
parent a8ef82166c
commit d689b3a301
7 changed files with 157 additions and 120 deletions

View File

@ -4,7 +4,7 @@ from typing import Union
from .enums import NBS, BusModes from .enums import NBS, BusModes
from .iremote import IRemote from .iremote import IRemote
from .meta import bus_mode_prop, channel_bool_prop, channel_label_prop from .meta import bus_mode_prop, channel_bool_prop, channel_int_prop, channel_label_prop
class Bus(IRemote): class Bus(IRemote):
@ -90,20 +90,9 @@ class BusLevel(IRemote):
def getter(self): def getter(self):
"""Returns a tuple of level values for the channel.""" """Returns a tuple of level values for the channel."""
def fget(i):
return round((((1 << 16) - 1) - i) * -0.01, 1)
if not self._remote.stopped() and self._remote.event.ldirty: if not self._remote.stopped() and self._remote.event.ldirty:
return tuple( return self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
fget(i) return self.public_packets[NBS.zero].levels.bus[self.range[0] : self.range[-1]]
for i in self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
)
return tuple(
fget(i)
for i in self._remote._get_levels(self.public_packets[NBS.zero])[1][
self.range[0] : self.range[-1]
]
)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
@ -188,7 +177,8 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
'eq': BusEQ.make(remote, i), 'eq': BusEQ.make(remote, i),
'levels': BusLevel(remote, i), 'levels': BusLevel(remote, i),
'mode': BUSMODEMIXIN_cls(remote, i), 'mode': BUSMODEMIXIN_cls(remote, i),
**{param: channel_bool_prop(param) for param in ['mute', 'mono']}, **{param: channel_bool_prop(param) for param in ('mute',)},
**{param: channel_int_prop(param) for param in ('mono',)},
'label': channel_label_prop(), 'label': channel_label_prop(),
}, },
)(remote, i) )(remote, i)

View File

@ -1,7 +1,7 @@
from functools import partial from functools import partial
from .enums import NBS from .enums import NBS
from .util import cache_bool, cache_float, cache_string from .util import cache_bool, cache_float, cache_int, cache_string
def channel_bool_prop(param): def channel_bool_prop(param):
@ -29,18 +29,39 @@ def channel_bool_prop(param):
return property(fget, fset) return property(fget, fset)
def channel_int_prop(param):
"""meta function for channel integer parameters"""
@partial(cache_int, param=param)
def fget(self):
cmd = self._cmd(param)
self.logger.debug(f'getter: {cmd}')
return int.from_bytes(
getattr(
self.public_packets[NBS.zero],
f'{"strip" if "strip" in type(self).__name__.lower() else "bus"}state',
)[self.index],
'little',
) & getattr(self._modes, f'_{param.lower()}')
def fset(self, val):
self.setter(param, val)
return property(fget, fset)
def channel_label_prop(): def channel_label_prop():
"""meta function for channel label parameters""" """meta function for channel label parameters"""
@partial(cache_string, param='label') @partial(cache_string, param='label')
def fget(self) -> str: def fget(self) -> str:
return getattr( if 'strip' in type(self).__name__.lower():
self.public_packets[NBS.zero], return self.public_packets[NBS.zero].labels.strip[self.index]
f'{"strip" if "strip" in type(self).__name__.lower() else "bus"}labels', else:
)[self.index] return self.public_packets[NBS.zero].labels.bus[self.index]
def fset(self, val: str): def fset(self, val: str):
self.setter('label', str(val)) self.setter('label', f'"{val}"')
return property(fget, fset) return property(fget, fset)

View File

@ -19,8 +19,8 @@ VMPARAMSTRIP_SIZE = 174
@dataclass @dataclass
class VbanRtPacket: class VbanPacket:
"""Represents the body of a VBAN RT data packet""" """Represents the header of a VBAN data packet"""
nbs: NBS nbs: NBS
_kind: KindMapClass _kind: KindMapClass
@ -31,10 +31,42 @@ class VbanRtPacket:
_optionBits: bytes _optionBits: bytes
_samplerate: bytes _samplerate: bytes
@property
def voicemeetertype(self) -> str:
"""returns voicemeeter type as a string"""
return ['', 'basic', 'banana', 'potato'][
int.from_bytes(self._voicemeeterType, 'little')
]
@property
def voicemeeterversion(self) -> tuple:
"""returns voicemeeter version as a tuple"""
return tuple(self._voicemeeterVersion[i] for i in range(3, -1, -1))
@property
def samplerate(self) -> int:
"""returns samplerate as an int"""
return int.from_bytes(self._samplerate, 'little')
class Levels(NamedTuple):
strip: tuple[float, ...]
bus: tuple[float, ...]
class Labels(NamedTuple):
strip: tuple[str, ...]
bus: tuple[str, ...]
class States(NamedTuple):
strip: tuple[bytes, ...]
bus: tuple[bytes, ...]
@dataclass @dataclass
class VbanRtPacketNBS0(VbanRtPacket): class VbanPacketNBS0(VbanPacket):
"""Represents the body of a VBAN RT data packet with NBS 0""" """Represents the body of a VBAN data packet with ident:0"""
_inputLeveldB100: bytes _inputLeveldB100: bytes
_outputLeveldB100: bytes _outputLeveldB100: bytes
@ -82,20 +114,6 @@ class VbanRtPacketNBS0(VbanRtPacket):
_busLabelUTF8c60=data[932:1412], _busLabelUTF8c60=data[932:1412],
) )
def _generate_levels(self, levelarray) -> tuple:
return tuple(
int.from_bytes(levelarray[i : i + 2], 'little')
for i in range(0, len(levelarray), 2)
)
@property
def strip_levels(self):
return self._generate_levels(self._inputLeveldB100)
@property
def bus_levels(self):
return self._generate_levels(self._outputLeveldB100)
def pdirty(self, other) -> bool: def pdirty(self, other) -> bool:
"""True iff any defined parameter has changed""" """True iff any defined parameter has changed"""
@ -123,37 +141,34 @@ class VbanRtPacketNBS0(VbanRtPacket):
return any(any(li) for li in (self._strip_comp, self._bus_comp)) return any(any(li) for li in (self._strip_comp, self._bus_comp))
@property @property
def voicemeetertype(self) -> str: def strip_levels(self) -> tuple[int, ...]:
"""returns voicemeeter type as a string""" """Returns raw integer strip levels"""
type_ = ('basic', 'banana', 'potato')
return type_[int.from_bytes(self._voicemeeterType, 'little') - 1]
@property
def voicemeeterversion(self) -> tuple:
"""returns voicemeeter version as a tuple"""
return tuple( return tuple(
reversed( int.from_bytes(self._inputLeveldB100[i : i + 2], 'little')
tuple( for i in range(0, len(self._inputLeveldB100), 2)
int.from_bytes(self._voicemeeterVersion[i : i + 1], 'little')
for i in range(4)
)
)
) )
@property @property
def samplerate(self) -> int: def bus_levels(self) -> tuple[int, ...]:
"""returns samplerate as an int""" """Returns raw integer bus levels"""
return int.from_bytes(self._samplerate, 'little') return tuple(
int.from_bytes(self._outputLeveldB100[i : i + 2], 'little')
for i in range(0, len(self._outputLeveldB100), 2)
)
@property @property
def inputlevels(self) -> tuple: def levels(self) -> Levels:
"""returns the entire level array across all inputs for a kind""" """Returns strip and bus levels converted to dB"""
return self.strip_levels[0 : self._kind.num_strip_levels]
@property def to_db(raw_levels: tuple[int, ...]) -> tuple[float, ...]:
def outputlevels(self) -> tuple: return tuple(
"""returns the entire level array across all outputs for a kind""" round((((1 << 16) - 1) - level) * -0.01, 1) for level in raw_levels
return self.bus_levels[0 : self._kind.num_bus_levels] )
return Levels(
strip=to_db(self.strip_levels)[: self._kind.num_strip_levels],
bus=to_db(self.bus_levels)[: self._kind.num_bus_levels],
)
@property @property
def stripstate(self) -> tuple: def stripstate(self) -> tuple:
@ -202,19 +217,35 @@ class VbanRtPacketNBS0(VbanRtPacket):
) )
@property @property
def striplabels(self) -> tuple: def labels(self) -> Labels:
"""returns tuple of strip labels""" """returns Labels namedtuple of strip and bus labels"""
return tuple(
self._stripLabelUTF8c60[i : i + 60].decode().split('\x00')[0]
for i in range(0, 480, 60)
)
@property def _extract_labels_from_bytes(label_bytes: bytes) -> tuple[str, ...]:
def buslabels(self) -> tuple: """Extract null-terminated UTF-8 labels from 60-byte chunks"""
"""returns tuple of bus labels""" labels = []
return tuple( for i in range(0, len(label_bytes), 60):
self._busLabelUTF8c60[i : i + 60].decode().split('\x00')[0] chunk = label_bytes[i : i + 60]
for i in range(0, 480, 60) null_pos = chunk.find(b'\x00')
if null_pos == -1:
try:
label = chunk.decode('utf-8', errors='replace').rstrip('\x00')
except UnicodeDecodeError:
label = ''
else:
try:
label = (
chunk[:null_pos].decode('utf-8', errors='replace')
if null_pos > 0
else ''
)
except UnicodeDecodeError:
label = ''
labels.append(label)
return tuple(labels)
return Labels(
strip=_extract_labels_from_bytes(self._stripLabelUTF8c60),
bus=_extract_labels_from_bytes(self._busLabelUTF8c60),
) )
@ -535,8 +566,8 @@ class VbanVMParamStrip:
@dataclass @dataclass
class VbanRtPacketNBS1(VbanRtPacket): class VbanPacketNBS1(VbanPacket):
"""Represents the body of a VBAN RT data packet with NBS 1""" """Represents the body of a VBAN data packet with ident:1"""
strips: tuple[VbanVMParamStrip, ...] strips: tuple[VbanVMParamStrip, ...]

View File

@ -540,22 +540,11 @@ class StripLevel(IRemote):
def getter(self): def getter(self):
"""Returns a tuple of level values for the channel.""" """Returns a tuple of level values for the channel."""
def fget(i):
return round((((1 << 16) - 1) - i) * -0.01, 1)
if not self._remote.stopped() and self._remote.event.ldirty: if not self._remote.stopped() and self._remote.event.ldirty:
return tuple( return self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
fget(i) return self.public_packets[NBS.zero].levels.strip[
for i in self._remote.cache['strip_level'][ self.range[0] : self.range[-1]
self.range[0] : self.range[-1] ]
]
)
return tuple(
fget(i)
for i in self._remote._get_levels(self.public_packets[NBS.zero])[0][
self.range[0] : self.range[-1]
]
)
@property @property
def identifier(self) -> str: def identifier(self) -> str:

View File

@ -15,13 +15,27 @@ def cache_bool(func, param):
return wrapper return wrapper
def cache_int(func, param):
"""Check cache for an int prop"""
def wrapper(*args, **kwargs):
self, *rem = args
if self._cmd(param) in self._remote.cache:
return self._remote.cache.pop(self._cmd(param))
if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs)
return wrapper
def cache_string(func, param): def cache_string(func, param):
"""Check cache for a string prop""" """Check cache for a string prop"""
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
self, *rem = args self, *rem = args
if self._cmd(param) in self._remote.cache: if self._cmd(param) in self._remote.cache:
return self._remote.cache.pop(self._cmd(param)) return self._remote.cache.pop(self._cmd(param)).strip('"')
if self._remote.sync: if self._remote.sync:
self._remote.clear_dirty() self._remote.clear_dirty()
return func(*args, **kwargs) return func(*args, **kwargs)
@ -75,13 +89,19 @@ def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
Generator function, accepts two tuples. Generator function, accepts two tuples.
Evaluates equality of each member in both tuples. Evaluates equality of each member in both tuples.
Only ignores changes when levels are very quiet (below noise floor).
""" """
for a, b in zip(t0, t1): for a, b in zip(t0, t1):
if ((1 << 16) - 1) - b <= 7200: # Convert to dB-equivalent: higher raw values = quieter audio
yield a == b a_db_equiv = ((1 << 16) - 1) - a
b_db_equiv = ((1 << 16) - 1) - b
# If both values are very quiet (> -72dB equivalent), ignore small changes
if a_db_equiv > 7200 and b_db_equiv > 7200:
yield True # Both very quiet, ignore changes
else: else:
yield True yield a == b # At least one has significant level, detect changes
def deep_merge(dict1, dict2): def deep_merge(dict1, dict2):

View File

@ -5,7 +5,7 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from queue import Queue from queue import Queue
from typing import Iterable, Union from typing import Union
from .enums import NBS from .enums import NBS
from .error import VBANCMDError from .error import VBANCMDError
@ -184,17 +184,6 @@ class VbanCmd(abc.ABC):
while self.pdirty: while self.pdirty:
time.sleep(self.DELAY) time.sleep(self.DELAY)
def _get_levels(self, packet) -> Iterable:
"""
returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
strip levels in PREFADER mode.
"""
return (
packet.inputlevels,
packet.outputlevels,
)
def apply(self, data: dict): def apply(self, data: dict):
""" """
Sets all parameters of a dict Sets all parameters of a dict

View File

@ -10,10 +10,10 @@ from .packet import (
VBAN_PROTOCOL_SERVICE, VBAN_PROTOCOL_SERVICE,
VBAN_SERVICE_RTPACKET, VBAN_SERVICE_RTPACKET,
SubscribeHeader, SubscribeHeader,
VbanRtPacket, VbanPacket,
VbanPacketNBS0,
VbanPacketNBS1,
VbanRtPacketHeader, VbanRtPacketHeader,
VbanRtPacketNBS0,
VbanRtPacketNBS1,
) )
from .util import bump_framecounter from .util import bump_framecounter
@ -75,16 +75,16 @@ class Producer(threading.Thread):
( (
self._remote.cache['strip_level'], self._remote.cache['strip_level'],
self._remote.cache['bus_level'], self._remote.cache['bus_level'],
) = self._remote._get_levels(self._remote.public_packets[NBS.zero]) ) = self._remote.public_packets[NBS.zero].levels
def _get_rt(self) -> VbanRtPacket: def _get_rt(self) -> VbanPacket:
"""Attempt to fetch data packet until a valid one found""" """Attempt to fetch data packet until a valid one found"""
while True: while True:
if resp := self._fetch_rt_packet(): if resp := self._fetch_rt_packet():
return resp return resp
def _fetch_rt_packet(self) -> VbanRtPacket | None: def _fetch_rt_packet(self) -> VbanPacket | None:
try: try:
data, _ = self._remote.sock.recvfrom(2048) data, _ = self._remote.sock.recvfrom(2048)
if len(data) < HEADER_SIZE: if len(data) < HEADER_SIZE:
@ -99,12 +99,12 @@ class Producer(threading.Thread):
match response_header.format_nbs: match response_header.format_nbs:
case NBS.zero: case NBS.zero:
return VbanRtPacketNBS0.from_bytes( return VbanPacketNBS0.from_bytes(
nbs=NBS.zero, kind=self._remote.kind, data=data nbs=NBS.zero, kind=self._remote.kind, data=data
) )
case NBS.one: case NBS.one:
return VbanRtPacketNBS1.from_bytes( return VbanPacketNBS1.from_bytes(
nbs=NBS.one, kind=self._remote.kind, data=data nbs=NBS.one, kind=self._remote.kind, data=data
) )
return None return None
@ -177,9 +177,6 @@ class Updater(threading.Thread):
( (
self._remote.cache['strip_level'], self._remote.cache['strip_level'],
self._remote.cache['bus_level'], self._remote.cache['bus_level'],
) = ( ) = self._remote.public_packets[NBS.zero].levels
self._remote._public_packets[NBS.zero].inputlevels,
self._remote._public_packets[NBS.zero].outputlevels,
)
self._remote.subject.notify(event) self._remote.subject.notify(event)
self.logger.debug(f'terminating {self.name} thread') self.logger.debug(f'terminating {self.name} thread')