8 Commits

Author SHA1 Message Date
23b99cb66b perform some path magic so Voicemeeter receives the entire path
patch bump
2026-03-01 21:29:09 +00:00
2fd7b8ad8b minor bump 2026-03-01 21:13:38 +00:00
c851cb5abe add Recorder section to README 2026-03-01 21:11:48 +00:00
dc681f50d0 add Recorder
add it to banana+potato
2026-03-01 21:10:10 +00:00
a0ec00652b reduce the level of logging for packet parse errors
patch bump
2026-03-01 17:22:06 +00:00
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
9 changed files with 357 additions and 74 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
@@ -345,6 +349,40 @@ vban.strip[0].fadeto(-10.3, 1000)
vban.bus[3].fadeby(-5.6, 500) vban.bus[3].fadeby(-5.6, 500)
``` ```
### Recorder
The following methods are available
- `play()`
- `stop()`
- `pause()`
- `record()`
- `ff()`
- `rew()`
- `load(filepath)`: raw string
- `goto(time_string)`: time string in format `hh:mm:ss`
The following properties are available
- `samplerate`: int, (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
- `bitresolution`: int, (8, 16, 24, 32)
- `channel`: int, from 1 to 8
- `kbps`: int, (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
- `gain`: float, from -60.0 to 12.0
example:
```python
vban.recorder.play()
vban.recorder.stop()
# filepath as raw string
vban.recorder.load(r'C:\music\mytune.mp3')
# set the goto time to 1m 30s
vban.recorder.goto('00:01:30')
```
### Command ### Command
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available: Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
@@ -511,7 +549,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 +568,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.8.1"
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" }
@@ -9,7 +9,7 @@ requires-python = ">=3.10"
dependencies = ["tomli (>=2.0.1,<3.0) ; python_version < '3.11'"] dependencies = ["tomli (>=2.0.1,<3.0) ; python_version < '3.11'"]
[tool.poetry.requires-plugins] [tool.poetry.requires-plugins]
poethepoet = "^0.35.0" poethepoet = ">=0.42.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3.4" pytest = "^8.3.4"

View File

@@ -11,6 +11,7 @@ from .error import VBANCMDError
from .kinds import KindMapClass from .kinds import KindMapClass
from .kinds import request_kind_map as kindmap from .kinds import request_kind_map as kindmap
from .macrobutton import MacroButton from .macrobutton import MacroButton
from .recorder import Recorder
from .strip import request_strip_obj as strip from .strip import request_strip_obj as strip
from .vban import request_vban_obj as vban from .vban import request_vban_obj as vban
from .vbancmd import VbanCmd from .vbancmd import VbanCmd
@@ -26,7 +27,7 @@ class FactoryBuilder:
""" """
BuilderProgress = IntEnum( BuilderProgress = IntEnum(
'BuilderProgress', 'strip bus command macrobutton vban', start=0 'BuilderProgress', 'strip bus command macrobutton vban recorder', start=0
) )
def __init__(self, factory, kind: KindMapClass): def __init__(self, factory, kind: KindMapClass):
@@ -38,6 +39,7 @@ class FactoryBuilder:
f'Finished building commands for {self._factory}', f'Finished building commands for {self._factory}',
f'Finished building macrobuttons for {self._factory}', f'Finished building macrobuttons for {self._factory}',
f'Finished building vban in/out streams for {self._factory}', f'Finished building vban in/out streams for {self._factory}',
f'Finished building recorder for {self._factory}',
) )
self.logger = logger.getChild(self.__class__.__name__) self.logger = logger.getChild(self.__class__.__name__)
@@ -72,6 +74,10 @@ class FactoryBuilder:
self._factory.vban = vban(self._factory) self._factory.vban = vban(self._factory)
return self return self
def make_recorder(self):
self._factory.recorder = Recorder.make(self._factory)
return self
class FactoryBase(VbanCmd): class FactoryBase(VbanCmd):
"""Base class for factories, subclasses VbanCmd.""" """Base class for factories, subclasses VbanCmd."""
@@ -85,7 +91,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,
@@ -166,7 +172,7 @@ class BananaFactory(FactoryBase):
@property @property
def steps(self) -> Iterable: def steps(self) -> Iterable:
"""steps required to build the interface for a kind""" """steps required to build the interface for a kind"""
return self._steps return self._steps + (self.builder.make_recorder,)
class PotatoFactory(FactoryBase): class PotatoFactory(FactoryBase):
@@ -188,7 +194,7 @@ class PotatoFactory(FactoryBase):
@property @property
def steps(self) -> Iterable: def steps(self) -> Iterable:
"""steps required to build the interface for a kind""" """steps required to build the interface for a kind"""
return self._steps return self._steps + (self.builder.make_recorder,)
def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd: def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
@@ -202,7 +208,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,17 +148,63 @@ 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 an 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

138
vban_cmd/recorder.py Normal file
View File

@@ -0,0 +1,138 @@
import os
import re
from .error import VBANCMDError
from .iremote import IRemote
from .meta import action_fn
class Recorder(IRemote):
"""
Implements the common interface
Defines concrete implementation for recorder
"""
@classmethod
def make(cls, remote):
"""
Factory function for recorder class.
Returns a Recorder class of a kind.
"""
Recorder_cls = type(
f'Recorder{remote.kind}',
(cls,),
{
**{
param: action_fn(param)
for param in [
'play',
'stop',
'pause',
'replay',
'record',
'ff',
'rew',
]
},
},
)
return Recorder_cls(remote)
def __str__(self):
return f'{type(self).__name__}'
@property
def identifier(self) -> str:
return 'recorder'
@property
def samplerate(self) -> int:
return
@samplerate.setter
def samplerate(self, val: int):
opts = (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
if val not in opts:
self.logger.warning(f'samplerate got: {val} but expected a value in {opts}')
self.setter('samplerate', val)
@property
def bitresolution(self) -> int:
return
@bitresolution.setter
def bitresolution(self, val: int):
opts = (8, 16, 24, 32)
if val not in opts:
self.logger.warning(
f'bitresolution got: {val} but expected a value in {opts}'
)
self.setter('bitresolution', val)
@property
def channel(self) -> int:
return
@channel.setter
def channel(self, val: int):
if not 1 <= val <= 8:
self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
self.setter('channel', val)
@property
def kbps(self):
return
@kbps.setter
def kbps(self, val: int):
opts = (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
if val not in opts:
self.logger.warning(f'kbps got: {val} but expected a value in {opts}')
self.setter('kbps', val)
@property
def gain(self) -> float:
return
@gain.setter
def gain(self, val: float):
self.setter('gain', val)
def load(self, file: os.PathLike):
try:
# Convert to string, use forward slashes, and wrap in quotes for spaces
file_path = f'"{os.fspath(file).replace(chr(92), "/")}"'
self.setter('load', file_path)
except UnicodeError:
raise VBANCMDError('File full directory must be a raw string')
def goto(self, time_str):
def get_sec():
"""Get seconds from time string"""
h, m, s = time_str.split(':')
return int(h) * 3600 + int(m) * 60 + int(s)
time_str = str(time_str) # coerce the type
if (
re.match(
r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$',
time_str,
)
is not None
):
self.setter('goto', get_sec())
else:
self.logger.warning(
"goto expects a string that matches the format 'hh:mm:ss'"
)
def filetype(self, val: str):
opts = {'wav': 1, 'aiff': 2, 'bwf': 3, 'mp3': 100}
try:
self.setter('filetype', opts[val.lower()])
except KeyError:
self.logger.warning(
f'filetype got: {val} but expected a value in {list(opts.keys())}'
)

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,31 +87,31 @@ 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
response_header = VbanResponseHeader.from_bytes(data[:HEADER_SIZE])
if (
response_header.format_sr != VBAN_PROTOCOL_SERVICE
or response_header.format_nbc != VBAN_SERVICE_RTPACKET
):
return
match response_header.format_nbs:
case NBS.zero:
return VbanPacketNBS0.from_bytes(
nbs=NBS.zero, kind=self._remote.kind, data=data
)
case NBS.one:
return VbanPacketNBS1.from_bytes(
nbs=NBS.one, kind=self._remote.kind, data=data
)
return None
except TimeoutError as e: except TimeoutError as e:
self.logger.exception(f'{type(e).__name__}: {e}') self.logger.exception(f'{type(e).__name__}: {e}')
raise VBANCMDConnectionError( raise VBANCMDConnectionError(
f'timeout waiting for RtPacket from {self._remote.ip}' f'timeout waiting for response from {self._remote.ip}:{self._remote.port}'
) from e ) from e
try:
header = VbanResponseHeader.from_bytes(data[:HEADER_SIZE])
except ValueError as e:
self.logger.debug(f'Error parsing response packet: {e}')
return None
match header.format_nbs:
case NBS.zero:
return VbanPacketNBS0.from_bytes(
nbs=NBS.zero, kind=self._remote.kind, data=data
)
case NBS.one:
return VbanPacketNBS1.from_bytes(
nbs=NBS.one, kind=self._remote.kind, data=data
)
return None
def stopped(self): def stopped(self):
return self.stop_event.is_set() return self.stop_event.is_set()