48 Commits

Author SHA1 Message Date
onyx-and-iris
f6218d2032 add scripts.py 2022-11-07 20:26:06 +00:00
onyx-and-iris
4aacc60857 md fix 2022-11-05 12:16:25 +00:00
norm
8f9ac47d02 fix apply in readme. 2022-11-05 03:14:37 +00:00
norm
90e994c193 typo fix 2022-11-05 02:48:33 +00:00
onyx-and-iris
44cd13aa48 refactor examples
add scripts to pyproject
2022-10-28 20:19:05 +01:00
onyx-and-iris
87eb61170e blacken readme example.
fix bug in main.py
2022-10-19 21:10:59 +01:00
onyx-and-iris
01c99d5b31 init ldirty
patch bump
2022-10-19 14:32:54 +01:00
onyx-and-iris
3144a95e07 minor bump 2022-10-19 14:21:23 +01:00
onyx-and-iris
1833b28c8d Connection section added to README.
CHANGELOG updated to reflect changes.
2022-10-19 14:21:04 +01:00
onyx-and-iris
ee3a871d23 add a delimiter end of request string in _set_rt
fixes bug if more than a single command in request packet.

removed [{self.index}] from apply string. (duplicates)
2022-10-19 14:20:23 +01:00
onyx-and-iris
197f81aa73 assume vban.toml for observer example
add README to observer example
2022-10-18 15:20:20 +01:00
onyx-and-iris
362873c5be fix vban config name in example readme 2022-10-17 13:15:02 +01:00
onyx-and-iris
c86f7971b0 rewording in obs example 2022-10-17 13:14:08 +01:00
onyx-and-iris
bac60e5ed3 add vban.toml to gitignore
minor bump
2022-10-07 20:01:55 +01:00
onyx-and-iris
692acc8dd0 assume vban.toml in obs example
update README for obs example
2022-10-07 20:01:29 +01:00
onyx-and-iris
d57269f147 add ability to read conn info from toml 2022-10-07 20:00:56 +01:00
onyx-and-iris
be69d905c4 minor ver bump 2022-10-06 20:30:14 +01:00
onyx-and-iris
5ceb8f775a config.toml added to gitignore 2022-10-06 20:29:38 +01:00
onyx-and-iris
e0f4aab257 obs example added.
README for obs example added
2022-10-06 20:29:03 +01:00
onyx-and-iris
4ee37f54c5 fadto() fadeby() methods added to strip/bus classes
appgain(), appmute() methods added to virtualstrip class
2022-10-06 20:28:26 +01:00
onyx-and-iris
550df917fb add, remove now accept iterables
update README

patch bump
2022-10-06 18:07:41 +01:00
onyx-and-iris
2f82e0b1fc fix str format 2022-10-06 16:50:03 +01:00
onyx-and-iris
0c60fe3d5e add property setters in event class
use event property setters in examples

update README

patch bump
2022-10-06 16:45:15 +01:00
onyx-and-iris
243a43ac22 patch bump 2022-10-05 22:54:39 +01:00
onyx-and-iris
49354d6d55 lower threshold a level is considered dirty 2022-10-05 22:54:26 +01:00
onyx-and-iris
5c9ac4d78f patch bump 2022-10-04 15:43:56 +01:00
onyx-and-iris
02b21b6989 print bus level values in observer example 2022-10-04 15:43:09 +01:00
onyx-and-iris
4659cf7cdb util:
in comp, consider level value clean if below -60.0

vbancmd:
pass tuple expansion into string format in version method.
ldirty and _get_levels logic now moved into rt packet class
2022-10-04 15:42:36 +01:00
onyx-and-iris
8663aab2ce add fget() to level getters in strip, bus 2022-10-04 15:40:32 +01:00
onyx-and-iris
a029011012 vbanrtpacket refactored
_generate_levels method added
ldirty method added.

moved initialize strip_level, bus_level cache into updater init()
initialize comps in updater init()
2022-10-04 15:39:56 +01:00
onyx-and-iris
bfa1a718f9 user logger in apply_config
patch bump
2022-09-29 12:34:02 +01:00
onyx-and-iris
2048a807d1 move event info logging from Updater into VbanCmd
odd logout logging

patch bump
2022-09-29 11:48:30 +01:00
onyx-and-iris
566bff3ced move vbancmd class section in readme 2022-09-28 20:01:17 +01:00
onyx-and-iris
70dbee6f02 update changelog to refect changes 2022-09-28 18:31:35 +01:00
onyx-and-iris
c14196fc31 minor version bump 2022-09-28 18:20:25 +01:00
onyx-and-iris
c28398c5f6 vban.subject subsection added to README under Events 2022-09-28 18:15:08 +01:00
onyx-and-iris
5177c2d297 fix erroneous call to self.vm
logging level INFO added
2022-09-28 18:14:06 +01:00
onyx-and-iris
23bc15e437 logging module now used to log interface events.
register, deregister method aliases added to Subject class.
2022-09-28 18:13:07 +01:00
onyx-and-iris
db96872965 changes to level/gain properties in VbanRtPacket
level getters in strip, bus fetch from public packet if not in cache
2022-09-28 18:07:10 +01:00
onyx-and-iris
1169435104 base renamed to vbancmd
misc renamed to event

info message fixed if no events subbed to

now using logging module in Event class
2022-09-28 18:03:22 +01:00
onyx-and-iris
f46abedf12 fix name of base error class in readme
patch bump
2022-09-24 07:49:17 +01:00
onyx-and-iris
733fab45b4 raise VBANCMD error on connection failure.
leave teardown procedures to consumer library. (or context manager)
2022-09-24 07:45:28 +01:00
onyx-and-iris
444f95a9d6 add timeout to response socket in updater
patch bump
2022-09-23 20:03:16 +01:00
onyx-and-iris
14e538dca6 patch bump 2022-09-03 20:43:47 +01:00
onyx-and-iris
af5e81c339 remove debug print 2022-09-03 20:41:26 +01:00
onyx-and-iris
aadfbd3925 fix regression causing pdirty update to fail.
patch bump
2022-09-03 20:35:37 +01:00
onyx-and-iris
4ef3d1f225 tomli/tomllib compatibility layer added.
Type annotation Self removed.

python version requirement changed.

tomli added as runtime dependency if py ver < 3.11

minor version bump.
2022-09-03 16:47:38 +01:00
onyx-and-iris
aea2be624e clean up class names in packet module.
add __init__ to vbanrtpacket class.

patch bump
2022-08-10 17:49:21 +01:00
26 changed files with 747 additions and 436 deletions

10
.gitignore vendored
View File

@@ -1,6 +1,3 @@
# quick test
quick.py
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -153,3 +150,10 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# quick test
quick.py
#config
config.toml
vban.toml

View File

@@ -11,6 +11,68 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x]
## [1.8.0]
### Added
- Connection section to README.
### Changed
- now using clear_dirty() when sync enabled.
### Fixed
- bug in set_rt() where multiple commands sent in single request packet.
- bug in apply where index was sent twice.
## [1.7.0]
### Added
- ability to read conn info from vban.toml config
### Changed
- assume a vban.toml in examples. README's modified.
## [1.6.0] - 2022-10-06
### Added
- fadeto(), fadeby() methods added to strip/bus classes.
- OBS example added.
### Changed
- Event class add/remove now accept iterables.
- property setters added to Event class.
- ldirty logic moved into VbanRtPacket class.
- in util, threshold a level is considered dirty moved to 7200 (-72.0)
- now print bus levels in observer example.
### Fixed
- initialize comps in updater thread. fixes bug when switching to a kind before any level updates
## [1.5.0] - 2022-09-28
### Changed
- Logging module used in place of print statements across the interface.
- base error name changed (VBANCMDError)
### Fixed
- Timeout and raise connection error when socket connection fails.
- Bug in observer example
## [1.4.0] - 2022-09-03
### Added
- tomli/tomllib compatibility layer to support python 3.10
## [1.3.0] - 2022-08-02
### Added

172
README.md
View File

@@ -25,24 +25,35 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Requirements
- [Voicemeeter](https://voicemeeter.com/)
- Python 3.11 or greater
- Python 3.10 or greater
## Installation
### `Pip`
Install vban-cmd package from your console
`pip install vban-cmd`
## `Use`
#### Connection
Load VBAN connection info from toml config. A valid `vban.toml` might look like this:
```toml
[connection]
ip = "gamepc.local"
port = 6980
streamname = "Command1"
```
It should be placed next to your `__main__.py` file.
Alternatively you may pass `ip`, `port`, `streamname` as keyword arguments.
#### `__main__.py`
Simplest use case, use a context manager to request a VbanCmd class of a kind.
Login and logout are handled for you in this scenario.
#### `__main__.py`
```python
import vban_cmd
@@ -59,17 +70,21 @@ class ManyThings:
)
def other_things(self):
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True
info = (
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
)
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True
print("\n".join(info))
def main():
with vban_cmd.api(kind_id, **opts) as vban:
kind_id = "banana"
with vban_cmd.api(
kind_id, ip="gamepc.local", port=6980, streamname="Command1"
) as vban:
do = ManyThings(vban)
do.things()
do.other_things()
@@ -84,13 +99,6 @@ def main():
if __name__ == "__main__":
kind_id = "banana"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main()
```
@@ -124,7 +132,24 @@ example:
```python
vban.strip[3].gain = 3.7
print(strip[0].label)
print(vban.strip[0].label)
```
The following methods are available.
- `appgain(name, value)`: string, float, from 0.0 to 1.0
Set the gain in db by value for the app matching name.
- `appmute(name, value)`: string, bool
Set mute state as value for the app matching name.
example:
```python
vban.strip[5].appmute("Spotify", True)
vban.strip[5].appgain("Spotify", 0.5)
```
##### Gainlayers
@@ -213,6 +238,22 @@ print(vban.bus[0].levels.all)
`levels.all` will return -200.0 if no audio detected.
### Strip | Bus
The following methods are available.
- `fadeto(amount, time)`: float, int
- `fadeby(amount, time)`: float, int
Modify gain to or by the selected amount in db over a time interval in ms.
example:
```python
vban.strip[0].fadeto(-10.3, 1000)
vban.bus[3].fadeby(-5.6, 500)
```
### Command
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
@@ -242,8 +283,8 @@ vban.command.showvbanchat = true
```python
vban.apply(
{
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
"bus-2": {"mute": True},
"strip-0": {"A1": True, "B1": True, "gain": -6.0},
"bus-1": {"mute": True, "mode": "composite"},
}
)
```
@@ -252,7 +293,7 @@ Or for each class you may do:
```python
vban.strip[0].apply(mute: true, gain: 3.2, A1: true)
vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24)
vban.bus[0].apply(A1: true)
```
## Config Files
@@ -271,33 +312,9 @@ with vban_cmd.api('banana') as vban:
will load a config file at configs/banana/example.toml for Voicemeeter Banana.
## `Base Module`
## Events
### VbanCmd class
`vban_cmd.api(kind_id: str, **opts: dict)`
You may pass the following optional keyword arguments:
- `ip`: str, ip or hostname of remote machine
- `streamname`: str, name of the stream to connect to.
- `port`: int=6980, vban udp port of remote machine.
- `subs`: dict={"pdirty": True, "ldirty": False}, controls which updates to listen for.
- `pdirty`: parameter updates
- `ldirty`: level updates
#### Event updates
To receive event updates you should do the following:
- register your app to receive updates using the `vban.subject.add(observer)` method, where observer is your app.
- define an `on_update(subject)` callback function in your app. The value of subject may be checked for the type of update.
See `examples/observer` for a demonstration.
Level updates are considered high volume, by default they are NOT listened for.
Each of the update types may be enabled/disabled separately.
Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
example:
@@ -314,21 +331,72 @@ with vban_cmd.api('banana', **opts) as vban:
...
```
#### `vban.event`
#### `vban.subject`
You may also add/remove event subscriptions as necessary with the Event class.
Use the Subject class to register an app as event observer.
The following methods are available:
- `add`: registers an app as an event observer
- `remove`: deregisters an app as an event observer
example:
```python
vban.event.add("ldirty")
# register an app to receive updates
class App():
def __init__(self, vban):
vban.subject.add(self)
...
```
vban.event.remove("pdirty")
#### `vban.event`
Use the event class to toggle updates as necessary.
The following properties are available:
- `pdirty`: boolean
- `ldirty`: boolean
example:
```python
vban.event.ldirty = True
vban.event.pdirty = False
```
Or add, remove a list of events.
The following methods are available:
- `add()`
- `remove()`
- `get()`
example:
```python
vban.event.remove(["pdirty", "ldirty"])
# get a list of currently subscribed
print(vban.event.get())
```
## VbanCmd class
`vban_cmd.api(kind_id: str, **opts: dict)`
You may pass the following optional keyword arguments:
- `ip`: str, ip or hostname of remote machine
- `streamname`: str, name of the stream to connect to.
- `port`: int=6980, vban udp port of remote machine.
- `subs`: dict={"pdirty": True, "ldirty": False}, controls which updates to listen for.
- `pdirty`: parameter updates
- `ldirty`: level updates
#### `vban.pdirty`
True iff a parameter has been changed.
@@ -351,7 +419,7 @@ Returns a Voicemeeter rt data packet object. Designed to be used internally by t
### `Errors`
- `errors.VMCMDErrors`: Base VMCMD error class.
- `errors.VBANCMDError`: Base VMCMD error class.
### `Tests`

View File

@@ -13,17 +13,21 @@ class ManyThings:
)
def other_things(self):
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True
info = (
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
)
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True
print("\n".join(info))
def main():
with vban_cmd.api(kind_id, **opts) as vban:
kind_id = "banana"
with vban_cmd.api(
kind_id, ip="gamepc.local", port=6980, streamname="Command1"
) as vban:
do = ManyThings(vban)
do.things()
do.other_things()
@@ -33,19 +37,9 @@ def main():
{
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
"bus-2": {"mute": True},
"button-0": {"state": True},
"vban-in-0": {"on": True},
"vban-out-1": {"name": "streamname"},
}
)
if __name__ == "__main__":
kind_id = "banana"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main()

51
examples/obs/README.md Normal file
View File

@@ -0,0 +1,51 @@
## Requirements
- [OBS Studio](https://obsproject.com/)
- [OBS Python SDK for Websocket v5](https://github.com/aatikturk/obsws-python)
## About
Perhaps you have a streaming setup but you want to control OBS and Voicemeeter from a remote location with python installed.
With the vban-cmd and obsws-python packages you may sync a distant Voicemeeter with a distant OBS over LAN.
## Configure
This script assumes the following:
- OBS Connection info in a valid `config.toml`:
```toml
[connection]
host = "gamepc.local"
port = 4455
password = "mystrongpass"
```
- VBAN Connection info in a valid `vban.toml`:
```toml
[connection]
ip = "gamepc.local"
port = 6980
streamname = "Command1"
```
- Both configs should be placed next to `__main__.py`.
- Four OBS scenes named "START", "BRB", "END" and "LIVE".
## Use
Make sure you have established a working connection to OBS and the remote Voicemeeter.
Run the script, change OBS scenes and watch Voicemeeter parameters change.
Pressing `<Enter>` will exit.
## Notes
This script can be run from a Linux host since the vban-cmd interface relies on UDP packets and obsws-python runs over websockets.
You could for example, set this up to run in the background on a home server such as a Raspberry Pi.
It requires Python 3.10+.

63
examples/obs/__main__.py Normal file
View File

@@ -0,0 +1,63 @@
import logging
import obsws_python as obs
import vban_cmd
logging.basicConfig(level=logging.INFO)
class Observer:
def __init__(self, vban):
self.vban = vban
self.client = obs.EventClient()
self.client.callback.register(self.on_current_program_scene_changed)
def on_start(self):
self.vban.strip[0].mute = True
self.vban.strip[1].B1 = True
self.vban.strip[2].B2 = True
def on_brb(self):
self.vban.strip[7].fadeto(0, 500)
self.vban.bus[0].mute = True
def on_end(self):
self.vban.apply(
{
"strip-0": {"mute": True},
"strip-1": {"mute": True, "B1": False},
"strip-2": {"mute": True, "B1": False},
}
)
def on_live(self):
self.vban.strip[0].mute = False
self.vban.strip[7].fadeto(-6, 500)
self.vban.strip[7].A3 = True
def on_current_program_scene_changed(self, data):
def fget(scene):
run = {
"START": self.on_start,
"BRB": self.on_brb,
"END": self.on_end,
"LIVE": self.on_live,
}
return run.get(scene)
scene = data.scene_name
print(f"Switched to scene {scene}")
if fn := fget(scene):
fn()
def main():
with vban_cmd.api("potato", sync=True) as vban:
obs = Observer(vban)
while cmd := input("<Enter> to exit\n"):
if not cmd:
break
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
## About
Registers a class as an observer and defines a callback.
## Configure
The script assumes you have connection info saved in a config file named `vban.toml` placed next to `__main__.py`.
A valid `vban.toml` might look like this:
```toml
[connection]
ip = "gamepc.local"
port = 6980
streamname = "Command1"
```
It should be placed next to `__main__.py`.
## Use
Make sure you have established a working VBAN connection.
Run the script, then:
- change GUI parameters to trigger pdirty
- play audio through any bus to trigger ldirty
Pressing `<Enter>` will exit.

View File

@@ -1,35 +1,33 @@
import logging
import vban_cmd
logging.basicConfig(level=logging.INFO)
class Observer:
def __init__(self, vban):
self.vban = vban
# register your app as event observer
self.vban.subject.add(self)
# add level updates, since they are disabled by default.
self.vm.event.add("ldirty")
# enable level updates, since they are disabled by default.
self.vban.event.ldirty = True
# define an 'on_update' callback function to receive event updates
def on_update(self, subject):
if subject == "pdirty":
print("pdirty!")
elif subject == "ldirty":
info = (
f"[{self.vban.bus[0]} {self.vban.bus[0].levels.isdirty}]",
f"[{self.vban.bus[1]} {self.vban.bus[1].levels.isdirty}]",
f"[{self.vban.bus[2]} {self.vban.bus[2].levels.isdirty}]",
f"[{self.vban.bus[3]} {self.vban.bus[3].levels.isdirty}]",
f"[{self.vban.bus[4]} {self.vban.bus[4].levels.isdirty}]",
f"[{self.vban.bus[5]} {self.vban.bus[5].levels.isdirty}]",
f"[{self.vban.bus[6]} {self.vban.bus[6].levels.isdirty}]",
f"[{self.vban.bus[7]} {self.vban.bus[7].levels.isdirty}]",
)
print(" ".join(info))
for bus in self.vban.bus:
if bus.levels.isdirty:
print(bus, bus.levels.all)
def main():
with vban_cmd.api(kind_id, **opts) as vban:
obs = Observer(vban)
kind_id = "potato"
with vban_cmd.api(kind_id) as vban:
Observer(vban)
while cmd := input("Press <Enter> to exit\n"):
if not cmd:
@@ -37,11 +35,4 @@ def main():
if __name__ == "__main__":
kind_id = "potato"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main()

125
poetry.lock generated
View File

@@ -1,11 +1,3 @@
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "22.1.0"
@@ -22,7 +14,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
[[package]]
name = "black"
version = "22.6.0"
version = "22.8.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
@@ -33,6 +25,7 @@ click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
@@ -102,11 +95,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "pathspec"
version = "0.9.0"
version = "0.10.1"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
python-versions = ">=3.7"
[[package]]
name = "platformdirs"
@@ -129,8 +122,8 @@ optional = false
python-versions = ">=3.6"
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
testing = ["pytest-benchmark", "pytest"]
dev = ["tox", "pre-commit"]
[[package]]
name = "py"
@@ -153,14 +146,13 @@ diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pytest"
version = "7.1.2"
version = "7.1.3"
description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
@@ -198,97 +190,30 @@ pytest = ">=3.6"
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.7"
[metadata]
lock-version = "1.1"
python-versions = "^3.11"
content-hash = "13366a58ff2f3fa0de2cb1e3de2f66fff612610fa66bb909201ebaa434cce014"
python-versions = "^3.10"
content-hash = "9f887ae517ade09119bf1f2cf77261d2445ae95857b69470ce1707f9791ce080"
[metadata.files]
atomicwrites = []
attrs = []
black = [
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
]
pytest-randomly = [
{file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"},
{file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"},
]
black = []
click = []
colorama = []
iniconfig = []
isort = []
mypy-extensions = []
packaging = []
pathspec = []
platformdirs = []
pluggy = []
py = []
pyparsing = []
pytest = []
pytest-randomly = []
pytest-repeat = []
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
tomli = []

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "vban-cmd"
version = "1.3.2"
version = "1.8.1"
description = "Python interface for the VBAN RT Packet Service (Sendtext)"
authors = ["onyx-and-iris <code@onyxandiris.online>"]
license = "MIT"
@@ -8,7 +8,8 @@ readme = "README.md"
repository = "https://github.com/onyx-and-iris/vban-cmd-python"
[tool.poetry.dependencies]
python = "^3.11"
python = "^3.10"
tomli = { version = "^2.0.1", python = "<3.11" }
[tool.poetry.dev-dependencies]
@@ -21,3 +22,7 @@ isort = "^5.10.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
obs = "scripts:ex_obs"
observer = "scripts:ex_observer"

12
scripts.py Normal file
View File

@@ -0,0 +1,12 @@
import subprocess
from pathlib import Path
def ex_obs():
path = Path.cwd() / "examples" / "obs" / "."
subprocess.run(["py", str(path)])
def ex_observer():
path = Path.cwd() / "examples" / "observer" / "."
subprocess.run(["py", str(path)])

View File

@@ -32,22 +32,25 @@ class Bus(IRemote):
def gain(self) -> float:
def fget():
val = self.public_packet.busgain[self.index]
if val < 10000:
return -val
elif val == ((1 << 16) - 1):
return 0
else:
return ((1 << 16) - 1) - val
if 0 <= val <= 1200:
return val * 0.01
return (((1 << 16) - 1) - val) * -0.01
val = self.getter("gain")
if val is None:
val = fget() * 0.01
return round(val, 1)
return round(val if val else fget(), 1)
@gain.setter
def gain(self, val: float):
self.setter("gain", val)
def fadeto(self, target: float, time_: int):
self.setter("FadeTo", f"({target}, {time_})")
time.sleep(self._remote.DELAY)
def fadeby(self, change: float, time_: int):
self.setter("FadeBy", f"({change}, {time_})")
time.sleep(self._remote.DELAY)
class PhysicalBus(Bus):
def __str__(self):
@@ -79,9 +82,19 @@ class BusLevel(IRemote):
def getter(self):
"""Returns a tuple of level values for the channel."""
def fget(i):
return round((((1 << 16) - 1) - i) * -0.01, 1)
if self._remote.running and self._remote.event.ldirty:
return tuple(
fget(i)
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]]
)
return tuple(
round(-i * 0.01, 1)
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]]
fget(i)
for i in self._remote._get_levels(self.public_packet)[1][
self.range[0] : self.range[-1]
]
)
@property

View File

@@ -1,4 +1,3 @@
from .error import VMCMDErrors
from .iremote import IRemote
from .meta import action_prop

View File

@@ -1,7 +1,11 @@
import itertools
import logging
from pathlib import Path
import tomllib
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
from .kinds import request_kind_map as kindmap
@@ -70,7 +74,6 @@ class TOMLStrBuilder:
class TOMLDataExtractor:
def __init__(self, file):
self._data = dict()
with open(file, "rb") as f:
self._data = tomllib.load(f)
@@ -116,6 +119,8 @@ class Loader(metaclass=SingletonType):
loads data into memory if not found
"""
logger = logging.getLogger("config.Loader")
def __init__(self, kind):
self._kind = kind
self._configs = dict()
@@ -129,14 +134,16 @@ class Loader(metaclass=SingletonType):
def parse(self, identifier, data):
if identifier in self._configs:
print(f"config file with name {identifier} already in memory, skipping..")
self.logger.info(
f"config file with name {identifier} already in memory, skipping.."
)
return False
self.parser = dataextraction_factory(data)
return True
def register(self, identifier, data=None):
self._configs[identifier] = data if data else self.parser.data
print(f"config {self.name}/{identifier} loaded into memory")
self.logger.info(f"config {self.name}/{identifier} loaded into memory")
def deregister(self):
self._configs.clear()
@@ -159,6 +166,7 @@ def loader(kind):
returns configs loaded into memory
"""
logger = logging.getLogger("config.loader")
loader = Loader(kind)
for path in (
@@ -167,7 +175,7 @@ def loader(kind):
Path.home() / "Documents/Voicemeeter" / "configs" / kind.name,
):
if path.is_dir():
print(f"Checking [{path}] for TOML config files:")
logger.info(f"Checking [{path}] for TOML config files:")
for file in path.glob("*.toml"):
identifier = file.with_suffix("").stem
if loader.parse(identifier, file):

View File

@@ -1,4 +1,4 @@
class VMCMDErrors(Exception):
class VBANCMDError(Exception):
"""general errors"""
pass

55
vban_cmd/event.py Normal file
View File

@@ -0,0 +1,55 @@
import logging
from typing import Iterable, Union
class Event:
"""Keeps track of event subscriptions"""
logger = logging.getLogger("event.event")
def __init__(self, subs: dict):
self.subs = subs
def info(self, msg=None):
info = (f"{msg} events",) if msg else ()
if self.any():
info += (f"now listening for {', '.join(self.get())} events",)
else:
info += (f"not listening for any events",)
self.logger.info(", ".join(info))
@property
def pdirty(self) -> bool:
return self.subs["pdirty"]
@pdirty.setter
def pdirty(self, val: bool):
self.subs["pdirty"] = val
self.info(f"pdirty {'added to' if val else 'removed from'}")
@property
def ldirty(self) -> bool:
return self.subs["ldirty"]
@ldirty.setter
def ldirty(self, val: bool):
self.subs["ldirty"] = val
self.info(f"ldirty {'added to' if val else 'removed from'}")
def get(self) -> list:
return [k for k, v in self.subs.items() if v]
def any(self) -> bool:
return any(self.subs.values())
def add(self, events: Union[str, Iterable[str]]):
if isinstance(events, str):
events = [events]
for event in events:
setattr(self, event, True)
def remove(self, events: Union[str, Iterable[str]]):
if isinstance(events, str):
events = [events]
for event in events:
setattr(self, event, False)

View File

@@ -1,15 +1,16 @@
import logging
from abc import abstractmethod
from enum import IntEnum
from functools import cached_property
from typing import Iterable, NoReturn, Self
from typing import Iterable, NoReturn
from .base import VbanCmd
from .bus import request_bus_obj as bus
from .command import Command
from .config import request_config as configs
from .kinds import KindMapClass
from .kinds import request_kind_map as kindmap
from .strip import request_strip_obj as strip
from .vbancmd import VbanCmd
class FactoryBuilder:
@@ -19,6 +20,7 @@ class FactoryBuilder:
Separates construction from representation.
"""
logger = logging.getLogger("vbancmd.factorybuilder")
BuilderProgress = IntEnum("BuilderProgress", "strip bus command", start=0)
def __init__(self, factory, kind: KindMapClass):
@@ -33,23 +35,23 @@ class FactoryBuilder:
def _pinfo(self, name: str) -> NoReturn:
"""prints progress status for each step"""
name = name.split("_")[1]
print(self._info[int(getattr(self.BuilderProgress, name))])
self.logger.info(self._info[int(getattr(self.BuilderProgress, name))])
def make_strip(self) -> Self:
def make_strip(self):
self._factory.strip = tuple(
strip(i < self.kind.phys_in, self._factory, i)
for i in range(self.kind.num_strip)
)
return self
def make_bus(self) -> Self:
def make_bus(self):
self._factory.bus = tuple(
bus(i < self.kind.phys_out, self._factory, i)
for i in range(self.kind.num_bus)
)
return self
def make_command(self) -> Self:
def make_command(self):
self._factory.command = Command.make(self._factory)
return self

View File

@@ -91,6 +91,8 @@ class IRemote(metaclass=ABCMeta):
cmd = f"{self.identifier}.{param}"
if cmd in self._remote.cache:
return self._remote.cache.pop(cmd)
if self._remote.sync:
self._remote.clear_dirty()
def setter(self, param, val):
"""Sends a string request RT packet."""
@@ -120,8 +122,8 @@ class IRemote(metaclass=ABCMeta):
if isinstance(val, bool):
val = 1 if val else 0
self._remote.cache[f"{self.identifier}[{self.index}].{attr}"] = val
script += f"{self.identifier}[{self.index}].{attr}={val};"
self._remote.cache[f"{self.identifier}.{attr}"] = val
script += f"{self.identifier}.{attr}={val};"
self._remote.sendtext(script)
return self

View File

@@ -1,6 +1,5 @@
from functools import partial
from .error import VMCMDErrors
from .util import cache_bool, cache_string

View File

@@ -1,32 +0,0 @@
class Event:
def __init__(self, subs: dict):
self.subs = subs
def info(self, msg):
info = (
f"{msg} events",
f"Now listening for {', '.join(self.get())} events",
)
print("\n".join(info))
@property
def pdirty(self):
return self.subs["pdirty"]
@property
def ldirty(self):
return self.subs["ldirty"]
def get(self) -> list:
return [k for k, v in self.subs.items() if v]
def any(self) -> bool:
return any(self.subs.values())
def add(self, event):
self.subs[event] = True
self.info(f"{event} added to")
def remove(self, event):
self.subs[event] = False
self.info(f"{event} removed from")

View File

@@ -1,6 +1,8 @@
from dataclasses import dataclass
from typing import Generator
from .util import comp
VBAN_SERVICE_RTPACKETREGISTER = 32
VBAN_SERVICE_RTPACKET = 33
MAX_PACKET_SIZE = 1436
@@ -8,40 +10,27 @@ HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 + 4
@dataclass
class VBAN_VMRT_Packet_Data:
"""Represents the structure of a VMRT data packet"""
class VbanRtPacket:
"""Represents the body of a VBAN RT data packet"""
_voicemeeterType: bytes
_reserved: bytes
_buffersize: bytes
_voicemeeterVersion: bytes
_optionBits: bytes
_samplerate: bytes
_inputLeveldB100: bytes
_outputLeveldB100: bytes
_TransportBit: bytes
_stripState: bytes
_busState: bytes
_stripGaindB100Layer1: bytes
_stripGaindB100Layer2: bytes
_stripGaindB100Layer3: bytes
_stripGaindB100Layer4: bytes
_stripGaindB100Layer5: bytes
_stripGaindB100Layer6: bytes
_stripGaindB100Layer7: bytes
_stripGaindB100Layer8: bytes
_busGaindB100: bytes
_stripLabelUTF8c60: bytes
_busLabelUTF8c60: bytes
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
self._strip_level = self._generate_levels(self._inputLeveldB100)
self._bus_level = self._generate_levels(self._outputLeveldB100)
def pdirty(self, other):
def _generate_levels(self, levelarray) -> tuple:
return tuple(
int.from_bytes(levelarray[i : i + 2], "little")
for i in range(0, len(levelarray), 2)
)
def pdirty(self, other) -> bool:
"""True iff any defined parameter has changed"""
return not (
self._stripState == other._stripState
and self._busState == other._busState
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
and self._busLabelUTF8c60 == other._busLabelUTF8c60
and self._stripGaindB100Layer1 == other._stripGaindB100Layer1
and self._stripGaindB100Layer2 == other._stripGaindB100Layer2
and self._stripGaindB100Layer3 == other._stripGaindB100Layer3
@@ -51,8 +40,17 @@ class VBAN_VMRT_Packet_Data:
and self._stripGaindB100Layer7 == other._stripGaindB100Layer7
and self._stripGaindB100Layer8 == other._stripGaindB100Layer8
and self._busGaindB100 == other._busGaindB100
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
and self._busLabelUTF8c60 == other._busLabelUTF8c60
)
def ldirty(self, strip_cache, bus_cache) -> bool:
self._strip_comp, self._bus_comp = (
tuple(not val for val in comp(strip_cache, self._strip_level)),
tuple(not val for val in comp(bus_cache, self._bus_level)),
)
return any(any(l) for l in (self._strip_comp, self._bus_comp))
@property
def voicemeetertype(self) -> str:
"""returns voicemeeter type as a string"""
@@ -77,24 +75,14 @@ class VBAN_VMRT_Packet_Data:
return int.from_bytes(self._samplerate, "little")
@property
def inputlevels(self) -> Generator[float, None, None]:
"""returns the entire level array across all inputs"""
for i in range(0, 68, 2):
val = ((1 << 16) - 1) - int.from_bytes(
self._inputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
def inputlevels(self) -> tuple:
"""returns the entire level array across all inputs for a kind"""
return self._strip_level[0 : (2 * self._kind.phys_in + 8 * self._kind.virt_in)]
@property
def outputlevels(self) -> Generator[float, None, None]:
"""returns the entire level array across all outputs"""
for i in range(0, 128, 2):
val = ((1 << 16) - 1) - int.from_bytes(
self._outputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
def outputlevels(self) -> tuple:
"""returns the entire level array across all outputs for a kind"""
return self._bus_level[0 : 8 * self._kind.num_bus]
@property
def stripstate(self) -> tuple:
@@ -114,64 +102,56 @@ class VBAN_VMRT_Packet_Data:
@property
def stripgainlayer1(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer1[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer1[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer2(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer2[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer2[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer3(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer3[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer3[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer4(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer4[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer4[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer5(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer5[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer5[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer6(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer6[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer6[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer7(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer7[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer7[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer8(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer8[i : i + 2], "little")
int.from_bytes(self._stripGaindB100Layer8[i : i + 2], "little")
for i in range(0, 16, 2)
)
@@ -179,7 +159,7 @@ class VBAN_VMRT_Packet_Data:
def busgain(self) -> tuple:
"""returns tuple of bus gains"""
return tuple(
((1 << 16) - 1) - int.from_bytes(self._busGaindB100[i : i + 2], "little")
int.from_bytes(self._busGaindB100[i : i + 2], "little")
for i in range(0, 16, 2)
)
@@ -201,8 +181,8 @@ class VBAN_VMRT_Packet_Data:
@dataclass
class VBAN_VMRT_Packet_Header:
"""Represents a RESPONSE RT PACKET header"""
class VbanRtPacketHeader:
"""Represents the header of VBAN RT data packet"""
name = "Voicemeeter-RTP"
vban: bytes = "VBAN".encode()
@@ -225,7 +205,7 @@ class VBAN_VMRT_Packet_Header:
@dataclass
class TextRequestHeader:
class RequestHeader:
"""Represents a REQUEST RT PACKET header"""
name: str
@@ -262,8 +242,8 @@ class TextRequestHeader:
@dataclass
class RegisterRTHeader:
"""Represents a REGISTER RT PACKET header"""
class SubscribeHeader:
"""Represents a packet used to subscribe to the RT Packet Service"""
name = "Register RTP"
timeout = 15

View File

@@ -1,3 +1,4 @@
import time
from abc import abstractmethod
from typing import Union
@@ -40,6 +41,14 @@ class Strip(IRemote):
def gain(self, val: float):
self.setter("gain", val)
def fadeto(self, target: float, time_: int):
self.setter("FadeTo", f"({target}, {time_})")
time.sleep(self._remote.DELAY)
def fadeby(self, change: float, time_: int):
self.setter("FadeBy", f"({change}, {time_})")
time.sleep(self._remote.DELAY)
class PhysicalStrip(Strip):
def __str__(self):
@@ -86,6 +95,12 @@ class VirtualStrip(Strip):
def k(self, val: int):
self.setter("karaoke", val)
def appgain(self, name: str, gain: float):
self.setter("AppGain", f'("{name}", {gain})')
def appmute(self, name: str, mute: bool = None):
self.setter("AppMute", f'("{name}", {1 if mute else 0})')
class StripLevel(IRemote):
def __init__(self, remote, index):
@@ -103,9 +118,23 @@ class StripLevel(IRemote):
self.range = self.level_map[self.index]
def getter(self):
"""Returns a tuple of level values for the channel."""
def fget(i):
return round((((1 << 16) - 1) - i) * -0.01, 1)
if self._remote.running and self._remote.event.ldirty:
return tuple(
fget(i)
for i in self._remote.cache["strip_level"][
self.range[0] : self.range[-1]
]
)
return tuple(
round(-i * 0.01, 1)
for i in self._remote.cache["strip_level"][self.range[0] : self.range[-1]]
fget(i)
for i in self._remote._get_levels(self.public_packet)[0][
self.range[0] : self.range[-1]
]
)
@property
@@ -149,17 +178,12 @@ class GainLayer(IRemote):
def gain(self) -> float:
def fget():
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index]
if val < 10000:
return -val
elif val == ((1 << 16) - 1):
return 0
else:
return ((1 << 16) - 1) - val
if 0 <= val <= 1200:
return val * 0.01
return (((1 << 16) - 1) - val) * -0.01
val = self.getter(f"GainLayer[{self._i}]")
if val is None:
val = fget() * 0.01
return round(val, 1)
return round(val if val else fget(), 1)
@gain.setter
def gain(self, val: float):

View File

@@ -1,6 +1,11 @@
import logging
class Subject:
"""Adds support for observers"""
logger = logging.getLogger("subject.subject")
def __init__(self):
"""list of current observers"""
@@ -22,16 +27,26 @@ class Subject:
if observer not in self._observers:
self._observers.append(observer)
self.logger.info(f"{type(observer).__name__} added to event observers")
else:
print(f"Failed to add: {observer}")
self.logger.error(
f"Failed to add {type(observer).__name__} to event observers"
)
register = add
def remove(self, observer):
"""removes an observer from _observers"""
try:
self._observers.remove(observer)
self.logger.info(f"{type(observer).__name__} removed from event observers")
except ValueError:
print(f"Failed to remove: {observer}")
self.logger.error(
f"Failed to remove {type(observer).__name__} from event observers"
)
deregister = remove
def clear(self):
"""clears the _observers list"""

View File

@@ -10,6 +10,8 @@ def cache_bool(func, param):
cmd = f"{self.identifier}.{param}"
if cmd in self._remote.cache:
return self._remote.cache.pop(cmd) == 1
if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs)
return wrapper
@@ -23,6 +25,8 @@ def cache_string(func, param):
cmd = f"{self.identifier}.{param}"
if cmd in self._remote.cache:
return self._remote.cache.pop(cmd)
if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs)
return wrapper
@@ -61,10 +65,12 @@ def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
Evaluates equality of each member in both tuples.
"""
for a, b in zip(t0, t1):
if b <= 9500:
if ((1 << 16) - 1) - b <= 7200:
yield a == b
yield True
else:
yield True
Socket = IntEnum("Socket", "register request response", start=0)

View File

@@ -1,17 +1,24 @@
import logging
import socket
import time
from abc import ABCMeta, abstractmethod
from typing import Iterable, NoReturn, Optional, Union
from pathlib import Path
from typing import Iterable, Optional, Union
from .misc import Event
from .packet import TextRequestHeader
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
from .event import Event
from .packet import RequestHeader
from .subject import Subject
from .util import Socket, comp, script
from .util import Socket, script
from .worker import Subscriber, Updater
class VbanCmd(metaclass=ABCMeta):
"""Base class responsible for communicating over VBAN RT Service"""
"""Base class responsible for communicating with the VBAN RT Packet Service"""
DELAY = 0.001
# fmt: off
@@ -21,12 +28,17 @@ class VbanCmd(metaclass=ABCMeta):
1000000, 1500000, 2000000, 3000000,
]
# fmt: on
logger = logging.getLogger("vbancmd.vbancmd")
def __init__(self, **kwargs):
for attr, val in kwargs.items():
setattr(self, attr, val)
if self.ip is None:
conn = self._conn_from_toml()
for attr, val in conn.items():
setattr(self, attr, val)
self.text_header = TextRequestHeader(
self.packet_request = RequestHeader(
name=self.streamname,
bps_index=self.BPS_OPTS.index(self.bps),
channel=self.channel,
@@ -35,14 +47,22 @@ class VbanCmd(metaclass=ABCMeta):
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in Socket
)
self.subject = Subject()
self.cache = dict()
self.cache = {}
self.event = Event(self.subs)
self._pdirty = False
self._ldirty = False
@abstractmethod
def __str__(self):
"""Ensure subclasses override str magic method"""
pass
def _conn_from_toml(self) -> str:
filepath = Path.cwd() / "vban.toml"
with open(filepath, "rb") as f:
conn = tomllib.load(f)
return conn["connection"]
def __enter__(self):
self.login()
return self
@@ -50,6 +70,7 @@ class VbanCmd(metaclass=ABCMeta):
def login(self):
"""Starts the subscriber and updater threads"""
self.running = True
self.event.info()
self.subscriber = Subscriber(self)
self.subscriber.start()
@@ -57,6 +78,8 @@ class VbanCmd(metaclass=ABCMeta):
self.updater = Updater(self)
self.updater.start()
self.logger.info(f"{type(self).__name__}: Successfully logged into {self}")
def _set_rt(
self,
id_: str,
@@ -64,17 +87,15 @@ class VbanCmd(metaclass=ABCMeta):
val: Optional[Union[int, float]] = None,
):
"""Sends a string request command over a network."""
cmd = id_ if not param else f"{id_}.{param}={val}"
cmd = id_ if not param else f"{id_}.{param}={val};"
self.socks[Socket.request].sendto(
self.text_header.header + cmd.encode(),
self.packet_request.header + cmd.encode(),
(socket.gethostbyname(self.ip), self.port),
)
count = int.from_bytes(self.text_header.framecounter, "little") + 1
self.text_header.framecounter = count.to_bytes(4, "little")
count = int.from_bytes(self.packet_request.framecounter, "little") + 1
self.packet_request.framecounter = count.to_bytes(4, "little")
if param:
self.cache[f"{id_}.{param}"] = val
if self.sync:
time.sleep(0.02)
@script
def sendtext(self, cmd):
@@ -90,8 +111,7 @@ class VbanCmd(metaclass=ABCMeta):
@property
def version(self) -> str:
"""Returns Voicemeeter's version as a string"""
v1, v2, v3, v4 = self.public_packet.voicemeeterversion
return f"{v1}.{v2}.{v3}.{v4}"
return "{0}.{1}.{2}.{3}".format(*self.public_packet.voicemeeterversion)
@property
def pdirty(self):
@@ -101,11 +121,7 @@ class VbanCmd(metaclass=ABCMeta):
@property
def ldirty(self):
"""True iff a level value has changed."""
self._strip_comp, self._bus_comp = (
tuple(not x for x in comp(self.cache["strip_level"], self._strip_buf)),
tuple(not x for x in comp(self.cache["bus_level"], self._bus_buf)),
)
return any(any(l) for l in (self._strip_comp, self._bus_comp))
return self._ldirty
@property
def public_packet(self):
@@ -113,7 +129,7 @@ class VbanCmd(metaclass=ABCMeta):
def clear_dirty(self):
while self.pdirty:
pass
time.sleep(self.DELAY)
def _get_levels(self, packet) -> Iterable:
"""
@@ -122,8 +138,8 @@ class VbanCmd(metaclass=ABCMeta):
strip levels in PREFADER mode.
"""
return (
tuple(val for val in packet.inputlevels),
tuple(val for val in packet.outputlevels),
packet.inputlevels,
packet.outputlevels,
)
def apply(self, data: dict):
@@ -151,14 +167,15 @@ class VbanCmd(metaclass=ABCMeta):
)
try:
self.apply(self.configs[name])
print(f"Profile '{name}' applied!")
self.logger.info(f"Profile '{name}' applied!")
except KeyError as e:
print(("\n").join(error_msg))
self.logger.error(("\n").join(error_msg))
def logout(self):
self.running = False
time.sleep(0.2)
[sock.close() for sock in self.socks]
self.logger.info(f"{type(self).__name__}: Successfully logged out of {self}")
def __exit__(self, exc_type, exc_value, exc_traceback):
self.logout()

View File

@@ -1,40 +1,36 @@
import logging
import socket
import threading
import time
from enum import IntEnum
from typing import Optional
from .packet import (
HEADER_SIZE,
RegisterRTHeader,
VBAN_VMRT_Packet_Data,
VBAN_VMRT_Packet_Header,
)
from .util import Socket
from .error import VBANCMDError
from .packet import HEADER_SIZE, SubscribeHeader, VbanRtPacket, VbanRtPacketHeader
from .util import Socket, comp
class Subscriber(threading.Thread):
"""fire a subscription packet every 10 seconds"""
def __init__(self, remote):
super().__init__(name="subscriber", target=self.register, daemon=True)
self._rem = remote
self.register_header = RegisterRTHeader()
super().__init__(name="subscriber", target=self.subscribe, daemon=True)
self._remote = remote
self.packet = SubscribeHeader()
def register(self):
while self._rem.running:
def subscribe(self):
while self._remote.running:
try:
self._rem.socks[Socket.register].sendto(
self.register_header.header,
(socket.gethostbyname(self._rem.ip), self._rem.port),
self._remote.socks[Socket.register].sendto(
self.packet.header,
(socket.gethostbyname(self._remote.ip), self._remote.port),
)
count = int.from_bytes(self.register_header.framecounter, "little") + 1
self.register_header.framecounter = count.to_bytes(4, "little")
count = int.from_bytes(self.packet.framecounter, "little") + 1
self.packet.framecounter = count.to_bytes(4, "little")
time.sleep(10)
except socket.gaierror as e:
print(f"Unable to resolve hostname {self._rem.ip}")
self._rem.socks[Socket.register].close()
raise e
except socket.gaierror:
err_msg = f"Unable to resolve hostname {self._remote.ip}"
print(err_msg)
raise VBANCMDError(err_msg)
class Updater(threading.Thread):
@@ -44,80 +40,103 @@ class Updater(threading.Thread):
notifies observers of event updates
"""
logger = logging.getLogger("worker.updater")
def __init__(self, remote):
super().__init__(name="updater", target=self.update, daemon=True)
self._rem = remote
self._rem.socks[Socket.response].bind(
(socket.gethostbyname(socket.gethostname()), self._rem.port)
self._remote = remote
self._remote.socks[Socket.response].settimeout(5)
self._remote.socks[Socket.response].bind(
(socket.gethostbyname(socket.gethostname()), self._remote.port)
)
self.expected_packet = VBAN_VMRT_Packet_Header()
self._rem._public_packet = self._get_rt()
self.packet_expected = VbanRtPacketHeader()
self._remote._public_packet = self._get_rt()
(
self._remote.cache["strip_level"],
self._remote.cache["bus_level"],
) = self._remote._get_levels(self._remote.public_packet)
p_in, v_in = self._remote.kind.ins
self._remote._strip_comp = [False] * (2 * p_in + 8 * v_in)
self._remote._bus_comp = [False] * (self._remote.kind.num_bus * 8)
def _fetch_rt_packet(self) -> Optional[VBAN_VMRT_Packet_Data]:
"""Returns a valid RT Data Packet or None"""
data, _ = self._rem.socks[Socket.response].recvfrom(2048)
# check for packet data
if len(data) > HEADER_SIZE:
# check if packet is of type VBAN
if self.expected_packet.header == data[: HEADER_SIZE - 4]:
return VBAN_VMRT_Packet_Data(
_voicemeeterType=data[28:29],
_reserved=data[29:30],
_buffersize=data[30:32],
_voicemeeterVersion=data[32:36],
_optionBits=data[36:40],
_samplerate=data[40:44],
_inputLeveldB100=data[44:112],
_outputLeveldB100=data[112:240],
_TransportBit=data[240:244],
_stripState=data[244:276],
_busState=data[276:308],
_stripGaindB100Layer1=data[308:324],
_stripGaindB100Layer2=data[324:340],
_stripGaindB100Layer3=data[340:356],
_stripGaindB100Layer4=data[356:372],
_stripGaindB100Layer5=data[372:388],
_stripGaindB100Layer6=data[388:404],
_stripGaindB100Layer7=data[404:420],
_stripGaindB100Layer8=data[420:436],
_busGaindB100=data[436:452],
_stripLabelUTF8c60=data[452:932],
_busLabelUTF8c60=data[932:1412],
)
def _fetch_rt_packet(self) -> Optional[VbanRtPacket]:
try:
data, _ = self._remote.socks[Socket.response].recvfrom(2048)
# check for packet data
if len(data) > HEADER_SIZE:
# check if packet is of type rt packet response
if self.packet_expected.header == data[: HEADER_SIZE - 4]:
self.logger.debug("valid packet received")
return VbanRtPacket(
_kind=self._remote.kind,
_voicemeeterType=data[28:29],
_reserved=data[29:30],
_buffersize=data[30:32],
_voicemeeterVersion=data[32:36],
_optionBits=data[36:40],
_samplerate=data[40:44],
_inputLeveldB100=data[44:112],
_outputLeveldB100=data[112:240],
_TransportBit=data[240:244],
_stripState=data[244:276],
_busState=data[276:308],
_stripGaindB100Layer1=data[308:324],
_stripGaindB100Layer2=data[324:340],
_stripGaindB100Layer3=data[340:356],
_stripGaindB100Layer4=data[356:372],
_stripGaindB100Layer5=data[372:388],
_stripGaindB100Layer6=data[388:404],
_stripGaindB100Layer7=data[404:420],
_stripGaindB100Layer8=data[420:436],
_busGaindB100=data[436:452],
_stripLabelUTF8c60=data[452:932],
_busLabelUTF8c60=data[932:1412],
)
except TimeoutError:
err_msg = f"Unable to establish connection with {self._remote.ip}"
print(err_msg)
raise VBANCMDError(err_msg)
def _get_rt(self) -> VBAN_VMRT_Packet_Data:
def _get_rt(self) -> VbanRtPacket:
"""Attempt to fetch data packet until a valid one found"""
def fget():
data = False
data = None
while not data:
data = self._fetch_rt_packet()
time.sleep(self._rem.DELAY)
time.sleep(self._remote.DELAY)
return data
return fget()
def update(self):
print(f"Listening for {', '.join(self._rem.event.get())} events")
(
self._rem.cache["strip_level"],
self._rem.cache["bus_level"],
) = self._rem._get_levels(self._rem.public_packet)
while self._rem.running:
while self._remote.running:
start = time.time()
_pp = self._get_rt()
self._rem._strip_buf, self._rem._bus_buf = self._rem._get_levels(_pp)
self._rem._pdirty = _pp.pdirty(self._rem.public_packet)
pdirty = _pp.pdirty(self._remote.public_packet)
ldirty = _pp.ldirty(
self._remote.cache["strip_level"], self._remote.cache["bus_level"]
)
if pdirty or ldirty:
self.logger.debug("dirty state, updating public packet")
self._remote._public_packet = _pp
self._remote._pdirty = pdirty
self._remote._ldirty = ldirty
if self._remote.event.pdirty and self._remote.pdirty:
self._remote.subject.notify("pdirty")
if self._remote.event.ldirty and self._remote.ldirty:
self._remote._strip_comp, self._remote._bus_comp = (
_pp._strip_comp,
_pp._bus_comp,
)
self._remote.cache["strip_level"], self._remote.cache["bus_level"] = (
_pp.inputlevels,
_pp.outputlevels,
)
self._remote.subject.notify("ldirty")
if self._rem.event.ldirty and self._rem.ldirty:
self._rem.cache["strip_level"] = self._rem._strip_buf
self._rem.cache["bus_level"] = self._rem._bus_buf
self._rem.subject.notify("ldirty")
if self._rem.public_packet != _pp:
self._rem._public_packet = _pp
if self._rem.event.pdirty and self._rem.pdirty:
self._rem.subject.notify("pdirty")
elapsed = time.time() - start
if self._rem.ratelimit - elapsed > 0:
time.sleep(self._rem.ratelimit - elapsed)
if self._remote.ratelimit - elapsed > 0:
time.sleep(self._remote.ratelimit - elapsed)