Compare commits

...

3 Commits

Author SHA1 Message Date
69263c22f2 add 2.7.0 to CHANGELOG 2026-03-01 17:04:37 +00:00
ad2cfeaae6 entry point now accepts a 'matrix' kind although it's main purpose is to disable the rt listener threads.
{VbanCmd}.sendtext():
- remove the @script decorator which I'm sure nobody has ever used anyway
- if rt listeners are disabled and it's a matrix query request, attempt to read a response.
2026-03-01 16:21:47 +00:00
1123fe6432 move header validation into class methods
add _parse_vban_service_header() helper function
2026-03-01 16:17:03 +00:00
8 changed files with 175 additions and 70 deletions

View File

@ -11,6 +11,24 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
## [2.7.0] - 2026-03-01
### Added
- new kind `matrix` has been added, it does two things:
- scales the interface according to `potato` kind, in practice this has no affect but it's required by the builder classes.
- disables the rt listener threads since we aren't expecting to receive any from a Matrix VBAN server.
- however, matrix responses may still be received with the {VbanCmd}.sendtext() method.
### Changed
- `outbound` kwarg has been renamed to `disable_rt_listeners`. Since it's job is to disable the listener threads for incoming RT packets this new name is more descriptive.
- dataclasses representing packet headers and packets with ident:0 and ident:1 have been moved into an internal packet module.
### Removed
- {VbanCmd}.sendtext() @script decorator removed. It's purpose was to attempt to convert a dictionary to a script but it was poorly implemented and there exists the {VbanCmd}.apply() method already.
## [2.6.0] - 2026-02-26 ## [2.6.0] - 2026-02-26
### Added ### Added

View File

@ -8,19 +8,19 @@
# VBAN CMD # VBAN CMD
This python interface allows you to transmit Voicemeeter parameters over a network. This python interface allows you to send Voicemeeter/Matrix commands over a network.
It may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python) It offers the same public API as [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python).
There is no support for audio transfer in this package, only parameters. Only the VBAN SERVICE/TEXT subprotocols are supported, there is no support for AUDIO or MIDI in this package.
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md) For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Tested against ## Tested against
- Basic 1.0.8.8 - Basic 1.1.2.2
- Banana 2.0.6.8 - Banana 2.1.2.2
- Potato 3.0.2.8 - Potato 3.1.2.2
## Requirements ## Requirements
@ -29,7 +29,9 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Installation ## Installation
`pip install vban-cmd` ```console
pip install vban-cmd
```
## `Use` ## `Use`
@ -113,6 +115,8 @@ Pass the kind of Voicemeeter as an argument. KIND_ID may be:
- `banana` - `banana`
- `potato` - `potato`
A fourth kind `matrix` has been added, if you pass it as a KIND_ID you are expected to use the [{VbanCmd}.sendtext()](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#vbansendtextscript) method for sending text requests.
## `Available commands` ## `Available commands`
### Strip ### Strip
@ -511,7 +515,8 @@ You may pass the following optional keyword arguments:
- `pdirty`: boolean=False, parameter updates - `pdirty`: boolean=False, parameter updates
- `ldirty`: boolean=False, level updates - `ldirty`: boolean=False, level updates
- `timeout`: int=5, amount of time (seconds) to wait for an incoming RT data packet (parameter states). - `timeout`: int=5, amount of time (seconds) to wait for an incoming RT data packet (parameter states).
- `outbound`: boolean=False, set `True` if you are only interested in sending commands. (no rt packets will be received) - `disable_rt_listeners`: boolean=False, set `True` if you don't wish to receive RT packets.
- You can still send Matrix string requests ending with `?` and receive a response.
#### `vban.pdirty` #### `vban.pdirty`
@ -529,6 +534,14 @@ Sends a script block as a string request, for example:
vban.sendtext('Strip[0].Mute=1;Bus[0].Mono=1') vban.sendtext('Strip[0].Mute=1;Bus[0].Mono=1')
``` ```
You can even use it to send matrix commands:
```python
vban.sendtext('Point(ASIO128.IN[1..4],ASIO128.OUT[1]).dBGain = -3.0')
vban.sendtext('Command.Version = ?')
```
## Errors ## Errors
- `errors.VBANCMDError`: Base VBANCMD Exception class. - `errors.VBANCMDError`: Base VBANCMD Exception class.

View File

@ -1,6 +1,6 @@
[project] [project]
name = "vban-cmd" name = "vban-cmd"
version = "2.6.0" version = "2.7.0"
description = "Python interface for the VBAN RT Packet Service (Sendtext)" description = "Python interface for the VBAN RT Packet Service (Sendtext)"
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }] authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
license = { text = "MIT" } license = { text = "MIT" }

View File

@ -85,7 +85,7 @@ class FactoryBase(VbanCmd):
'channel': 0, 'channel': 0,
'ratelimit': 0.01, 'ratelimit': 0.01,
'timeout': 5, 'timeout': 5,
'outbound': False, 'disable_rt_listeners': False,
'sync': False, 'sync': False,
'pdirty': False, 'pdirty': False,
'ldirty': False, 'ldirty': False,
@ -202,7 +202,13 @@ def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
_factory = BasicFactory _factory = BasicFactory
case 'banana': case 'banana':
_factory = BananaFactory _factory = BananaFactory
case 'potato': case 'potato' | 'matrix':
# matrix is a special kind where:
# - we don't need to scale the interface with the builder (in other words kind is arbitrary).
# - we don't ever need to use real-time listeners, so we disable them to avoid confusion
if kind_id == 'matrix':
kwargs['disable_rt_listeners'] = True
kind_id = 'potato'
_factory = PotatoFactory _factory = PotatoFactory
case _: case _:
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'") raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")

View File

@ -9,6 +9,9 @@ VBAN_PROTOCOL_SERVICE = 0x60
VBAN_SERVICE_RTPACKETREGISTER = 32 VBAN_SERVICE_RTPACKETREGISTER = 32
VBAN_SERVICE_RTPACKET = 33 VBAN_SERVICE_RTPACKET = 33
VBAN_SERVICE_MASK = 0xE0 VBAN_SERVICE_MASK = 0xE0
VBAN_PROTOCOL_MASK = 0xE0
VBAN_SERVICE_REQUESTREPLY = 0x02
VBAN_SERVICE_FNCT_REPLY = 0x02
MAX_PACKET_SIZE = 1436 MAX_PACKET_SIZE = 1436
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
@ -92,6 +95,38 @@ class VbanSubscribeHeader:
return bytes(data) return bytes(data)
def _parse_vban_service_header(data: bytes) -> dict:
"""Common parsing and validation for VBAN service protocol headers."""
if len(data) < HEADER_SIZE:
raise ValueError('Data is too short to be a valid VBAN header')
if data[:4] != b'VBAN':
raise ValueError('Invalid VBAN magic bytes')
format_sr = data[4]
format_nbs = data[5]
format_nbc = data[6]
format_bit = data[7]
# Verify this is a service protocol packet
protocol = format_sr & VBAN_PROTOCOL_MASK
if protocol != VBAN_PROTOCOL_SERVICE:
raise ValueError(f'Not a service protocol packet: {protocol:02x}')
# Extract stream name and frame counter
name = data[8:24].rstrip(b'\x00').decode('utf-8', errors='ignore')
framecounter = int.from_bytes(data[24:28], 'little')
return {
'format_sr': format_sr,
'format_nbs': format_nbs,
'format_nbc': format_nbc,
'format_bit': format_bit,
'name': name,
'framecounter': framecounter,
}
@dataclass @dataclass
class VbanResponseHeader: class VbanResponseHeader:
"""Represents the header of a response packet""" """Represents the header of a response packet"""
@ -101,6 +136,7 @@ class VbanResponseHeader:
format_nbs: int = 0 format_nbs: int = 0
format_nbc: int = VBAN_SERVICE_RTPACKET format_nbc: int = VBAN_SERVICE_RTPACKET
format_bit: int = 0 format_bit: int = 0
framecounter: int = 0
@property @property
def vban(self) -> bytes: def vban(self) -> bytes:
@ -112,18 +148,64 @@ class VbanResponseHeader:
@classmethod @classmethod
def from_bytes(cls, data: bytes): def from_bytes(cls, data: bytes):
if len(data) < HEADER_SIZE: """Parse a VbanResponseHeader from bytes."""
raise ValueError('Data is too short to be a valid VbanResponseHeader') parsed = _parse_vban_service_header(data)
name = data[8:24].rstrip(b'\x00').decode('utf-8') # Validate this is an RTPacket response
return cls( if parsed['format_nbc'] != VBAN_SERVICE_RTPACKET:
name=name, raise ValueError(
format_sr=data[4] & VBAN_SERVICE_MASK, f'Not a RTPacket response packet: {parsed["format_nbc"]:02x}'
format_nbs=data[5],
format_nbc=data[6],
format_bit=data[7],
) )
return cls(**parsed)
@dataclass
class VbanMatrixResponseHeader:
"""Represents the header of a matrix response packet"""
name: str = 'Request Reply'
format_sr: int = VBAN_PROTOCOL_SERVICE
format_nbs: int = VBAN_SERVICE_FNCT_REPLY
format_nbc: int = VBAN_SERVICE_REQUESTREPLY
format_bit: int = 0
framecounter: int = 0
@property
def vban(self) -> bytes:
return b'VBAN'
@property
def streamname(self) -> bytes:
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
@classmethod
def from_bytes(cls, data: bytes):
"""Parse a matrix response packet from bytes."""
parsed = _parse_vban_service_header(data)
# Validate this is a service reply packet
if parsed['format_nbs'] != VBAN_SERVICE_FNCT_REPLY:
raise ValueError(f'Not a service reply packet: {parsed["format_nbs"]:02x}')
return cls(**parsed)
@classmethod
def extract_payload(cls, data: bytes) -> str:
"""Extract the text payload from a matrix response packet."""
if len(data) <= HEADER_SIZE:
return ''
payload_bytes = data[HEADER_SIZE:]
return payload_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
@classmethod
def parse_response(cls, data: bytes) -> tuple['VbanMatrixResponseHeader', str]:
"""Parse a complete matrix response packet returning header and payload."""
header = cls.from_bytes(data)
payload = cls.extract_payload(data)
return header, payload
@dataclass @dataclass
class VbanRequestHeader: class VbanRequestHeader:

View File

@ -63,27 +63,6 @@ def depth(d):
return 0 return 0
def script(func):
"""Convert dictionary to script"""
def wrapper(*args):
remote, script = args
if isinstance(script, dict):
params = ''
for key, val in script.items():
obj, m2, *rem = key.split('-')
index = int(m2) if m2.isnumeric() else int(*rem)
params += ';'.join(
f'{obj}{f".{m2}stream" if not m2.isnumeric() else ""}[{index}].{k}={int(v) if isinstance(v, bool) else v}'
for k, v in val.items()
)
params += ';'
script = params
return func(remote, script)
return wrapper
def comp(t0: tuple, t1: tuple) -> Iterator[bool]: def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
""" """
Generator function, accepts two tuples of dB values. Generator function, accepts two tuples of dB values.

View File

@ -10,9 +10,9 @@ from typing import Union
from .enums import NBS from .enums import NBS
from .error import VBANCMDError from .error import VBANCMDError
from .event import Event from .event import Event
from .packet.headers import VbanRequestHeader from .packet.headers import VbanMatrixResponseHeader, VbanRequestHeader
from .subject import Subject from .subject import Subject
from .util import bump_framecounter, deep_merge, script from .util import bump_framecounter, deep_merge
from .worker import Producer, Subscriber, Updater from .worker import Producer, Subscriber, Updater
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -86,8 +86,8 @@ class VbanCmd(abc.ABC):
self.logout() self.logout()
def login(self) -> None: def login(self) -> None:
"""Starts the subscriber and updater threads (unless in outbound mode)""" """Starts the subscriber and updater threads (unless disable_rt_listeners is True) and logs into Voicemeeter."""
if not self.outbound: if not self.disable_rt_listeners:
self.event.info() self.event.info()
self.stop_event = threading.Event() self.stop_event = threading.Event()
@ -139,11 +139,20 @@ class VbanCmd(abc.ABC):
self._send_request(f'{cmd}={val};') self._send_request(f'{cmd}={val};')
self.cache[cmd] = val self.cache[cmd] = val
@script def sendtext(self, script) -> str | None:
def sendtext(self, script):
"""Sends a multiple parameter string over a network.""" """Sends a multiple parameter string over a network."""
self._send_request(script) self._send_request(script)
self.logger.debug(f'sendtext: {script}') self.logger.debug(f'sendtext: {script}')
if self.disable_rt_listeners and script.endswith(('?', '?;')):
try:
response = VbanMatrixResponseHeader.extract_payload(
self.sock.recv(1024)
)
return response
except ValueError as e:
self.logger.warning(f'Error extracting matrix response: {e}')
time.sleep(self.DELAY) time.sleep(self.DELAY)
@property @property

View File

@ -7,8 +7,6 @@ from .enums import NBS
from .error import VBANCMDConnectionError from .error import VBANCMDConnectionError
from .packet.headers import ( from .packet.headers import (
HEADER_SIZE, HEADER_SIZE,
VBAN_PROTOCOL_SERVICE,
VBAN_SERVICE_RTPACKET,
VbanPacket, VbanPacket,
VbanResponseHeader, VbanResponseHeader,
VbanSubscribeHeader, VbanSubscribeHeader,
@ -89,15 +87,19 @@ class Producer(threading.Thread):
data, _ = self._remote.sock.recvfrom(2048) data, _ = self._remote.sock.recvfrom(2048)
if len(data) < HEADER_SIZE: if len(data) < HEADER_SIZE:
return return
except TimeoutError as e:
self.logger.exception(f'{type(e).__name__}: {e}')
raise VBANCMDConnectionError(
f'timeout waiting for response from {self._remote.ip}:{self._remote.port}'
) from e
response_header = VbanResponseHeader.from_bytes(data[:HEADER_SIZE]) try:
if ( header = VbanResponseHeader.from_bytes(data[:HEADER_SIZE])
response_header.format_sr != VBAN_PROTOCOL_SERVICE except ValueError as e:
or response_header.format_nbc != VBAN_SERVICE_RTPACKET self.logger.warning(f'Error parsing response packet: {e}')
): return None
return
match response_header.format_nbs: match header.format_nbs:
case NBS.zero: case NBS.zero:
return VbanPacketNBS0.from_bytes( return VbanPacketNBS0.from_bytes(
nbs=NBS.zero, kind=self._remote.kind, data=data nbs=NBS.zero, kind=self._remote.kind, data=data
@ -107,12 +109,8 @@ class Producer(threading.Thread):
return VbanPacketNBS1.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
except TimeoutError as e:
self.logger.exception(f'{type(e).__name__}: {e}')
raise VBANCMDConnectionError(
f'timeout waiting for RtPacket from {self._remote.ip}'
) from e
def stopped(self): def stopped(self):
return self.stop_event.is_set() return self.stop_event.is_set()