130 Commits

Author SHA1 Message Date
9c0e2bef39 2.4.9 section added to CHANGELOG
patch bump
2023-08-13 18:20:28 +01:00
36692d1bc7 fixes error with escape character in regex 2023-08-13 18:16:49 +01:00
753714b639 should the loader attempt to load an invalid toml config
log as error but allow the loader to continue
2023-08-13 18:16:33 +01:00
27a26b8fe9 remove __str__ override 2023-08-13 18:15:31 +01:00
79260a0e47 check vban direction
check that index is numeric

remove button as possible key.
not defined in RT packets anyway

patch bump
2023-08-10 21:24:59 +01:00
f9bcbfa74a patch bump 2023-08-10 19:14:06 +01:00
0f2fb7121d add poetry test scripts for each kind 2023-08-10 19:13:34 +01:00
a635109308 make better use of pattern matching features
error test updated
2023-08-10 19:12:52 +01:00
a61e09b075 avoid using key word as variable name 2023-08-10 19:11:59 +01:00
763e44df12 refactor target
add error test for ValueError

test badges updated

patch bump
2023-08-09 17:03:55 +01:00
69472a783e patch bump 2023-08-07 17:39:39 +01:00
9a1ba06a21 update test badges 2023-08-07 17:39:26 +01:00
14b2ee473a mark config tests as slow 2023-08-07 17:39:13 +01:00
ca2427c29a lowercase identifiers 2023-08-07 17:38:51 +01:00
ebacdcf82a use _cmd() helper method to build cmd string 2023-08-07 17:38:37 +01:00
7416108489 add error tests 2023-08-07 16:31:19 +01:00
bd6e57b3c6 define message attribute for VBANCMD error classes
override str magic method
2023-08-07 16:31:08 +01:00
eed036ca03 patch bump 2023-08-05 14:06:47 +01:00
55211b9b19 replace generator function with factory function 2023-08-05 14:06:39 +01:00
4af7c0f694 initialize stop_event to None
in case outbound mode enabled
2023-08-05 14:05:18 +01:00
f082fa8ac5 reword 2023-08-05 13:40:32 +01:00
cbcca14481 rename until_stopped() to wait_until_stopped() 2023-08-05 13:36:36 +01:00
f584d53835 patch bump 2023-08-05 13:34:56 +01:00
72d182a488 use Threading.Event object to terminate threads
until_stopped() added to Subscriber thread
2023-08-04 23:13:58 +01:00
ee32f92914 add missing constants
add docstrings that describes data breakdown

move SubscribeHeader above  VbanRtPacketHeader

expand assert failure string
2023-08-04 23:06:51 +01:00
3b65035e50 add double click event for slider 2023-08-04 21:14:33 +01:00
c8b4bde49d patch bump 2023-08-04 16:33:48 +01:00
47e9203b1e use walrus 2023-08-04 16:21:57 +01:00
d48e7ecd79 Correct type annotations None type. 2023-08-02 17:19:08 +01:00
7e09a0d321 VBANCMDConnectionError now subclasses VBANCMDError 2023-08-02 15:45:25 +01:00
d41ee1a12a remove redundant __str__ overrides 2023-07-26 11:32:20 +01:00
1e499cd99d patch bump 2023-07-25 16:23:02 +01:00
9bf52b5c11 num_strip_levels, num_bus_levels added to KindMaps 2023-07-25 16:22:47 +01:00
77ba347e99 fix bus.eq.on example in readme 2023-07-15 08:17:18 +01:00
94fa33cebf md fix 2023-07-13 08:58:06 +01:00
ef105d878b fix logging example 2023-07-13 08:52:42 +01:00
956f759e73 add Logging section to README. 2023-07-13 08:50:24 +01:00
dab519be9f implement midi, text vban streams
kindmaps updated

factory tests updated.

closes #2
2023-07-12 10:24:03 +01:00
a4b91bf5c6 deep_merge implemented
recursively merges dicts in profiles

patch bump
2023-07-12 04:52:50 +01:00
2a98707bf8 Adds ability to extend one config with another
apply_config() checks for 'extends' in TOML config

2.3.0 section added to CHANGELOG

three example extender.toml configs added

minor version bump
2023-07-11 20:27:52 +01:00
8e30c57020 minor version bump 2023-07-08 17:25:53 +01:00
04e18b304b log params on successful connection
raise VBANCMDError if invalid config key in apply_config()
2023-07-08 17:25:38 +01:00
4de384c66c repr method added to factory base 2023-07-08 07:59:51 +01:00
2c8659a4e5 apply extended to support button, vban 2023-07-08 07:59:35 +01:00
41e427e46b button and vban classes added
button is a placeholder class, though.
2023-07-08 07:34:30 +01:00
fc6fdb44b5 Revert "remove setup.py"
This reverts commit b49dc3b9b3.
2023-07-07 19:04:15 +01:00
b49dc3b9b3 remove setup.py 2023-07-07 18:12:07 +01:00
1ad0347478 fixes bug with apply() if called from higher class 2023-07-05 19:20:57 +01:00
2c8e4cc87c rename sendtext_only to outbound
to more accurately describe its purpose.
2023-07-05 14:08:27 +01:00
fc3b31dfa7 fix error in readme 2023-07-05 03:19:57 +01:00
544e0f2a32 sendtext_only kwarg added.
readme, changelog updated.

minor version bump
2023-07-05 02:55:42 +01:00
f6d92d1c34 issue where subprocess not inheriting virtual env
see SO python-subprocess-doesnt-inherit-virtual-environment
2023-07-04 19:51:23 +01:00
10dbf63056 .python-version added to .gitignore 2023-06-30 17:56:54 +01:00
6ddd4151b4 add eq.on to apply example
VBANCMDConnectionError added to errors section
2023-06-27 15:36:53 +01:00
8b912a2d08 typo fix 2023-06-25 18:45:03 +01:00
d2a5fe197e version 2.0.0 section added to changelog
apply examples updated to include bus.eq.on

Strip.{Comp,Gate,Denioser} sections added to readme
2023-06-25 18:40:09 +01:00
0970bfe0b5 revert move data slices
strip_leves, bus_levels properties added to VbanRtPacket
2023-06-25 16:15:32 +01:00
54041503c9 add gui, tests to scripts
add tox to development dependencies

major version bump
2023-06-25 15:00:23 +01:00
9d015755eb single channel GUI example added. 2023-06-25 14:49:28 +01:00
ca9a31c94a example now registeres on_exit_started
script will now end when OBS is closed

filter out all logs but `vban_cmd.iremote`

setup.py added
2023-06-25 14:49:07 +01:00
7a3abfc372 rename subject to event.
use self.observer over self.subject
2023-06-25 14:47:48 +01:00
37a9c88867 remove deprecated eq tests 2023-06-25 14:24:04 +01:00
df7996a846 stip.{comp,gate} tests added to higher 2023-06-25 14:23:39 +01:00
3f5dc7c376 example.toml comp, gate, eq params updated 2023-06-25 13:59:44 +01:00
05cbc432b2 Strip.{comp,gate} setters added. 2023-06-25 13:59:08 +01:00
174d95d08d _conn_from_toml filepaths added. 2023-06-25 13:58:19 +01:00
fc324fecc4 run through black 2023-06-25 13:57:24 +01:00
449cb9b3c1 pdirty false by default 2023-06-25 13:53:23 +01:00
cdccc603d1 _cmd() helper method added
apply() extended to handle nested dicts

module level logger added
2023-06-25 13:52:39 +01:00
a8bb9711af added module level logger 2023-06-25 13:51:47 +01:00
5bb0c2731e run through black 2023-06-25 13:51:30 +01:00
372dba0b6b raise VBANCMDError on invalid kind 2023-06-25 13:50:21 +01:00
226fc5ead7 timeout kwarg added.
lets a user decide how long to wait for subscription response

pdirty now defaults to False
2023-06-25 12:21:02 +01:00
9196a4e267 subject class extended to support callbacks 2023-06-25 03:41:10 +01:00
8485992495 use name property, clears deprecation warning 2023-06-25 03:40:36 +01:00
91e49cbb55 tomllib/tomli now lazy loaded.
`Path.home() / "vban.toml" added to filepaths

`Path.home() / ".config" / "vban-cmd" / "vban.toml"` added to filepaths

VBANCMDError raised if ip not given and toml not located
2023-06-25 03:40:14 +01:00
3c85903554 renaem action_prop to action_fn 2023-06-25 02:38:59 +01:00
a730edc2c2 connection errors now raise VBANCMDConnectionError
Producer thread added, sends job queue to Updater

data slices moved back into dataclass
2023-06-25 02:37:45 +01:00
90acafe95b VBANCMDConnectionError added 2023-06-25 02:06:02 +01:00
5f4fdcb0eb StripComp, StripGate, StripDenoiser, StripDevice
added to PhysicalStrip
2023-06-25 01:48:07 +01:00
d5219d66f7 BusEQ added to Bus class 2023-06-25 01:47:05 +01:00
c74d827154 update strip.{comp,gate,eq} and bus.eq
add gain=0.0 to bus params.

`Path.home() / ".config" / "vban-cmd" / kind.name` added to loader
2023-06-25 01:43:26 +01:00
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
48 changed files with 2472 additions and 856 deletions

14
.gitignore vendored
View File

@@ -1,6 +1,3 @@
# quick test
quick.py
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -88,7 +85,7 @@ ipython_config.py
# pyenv # pyenv
# For a library or package, you might want to ignore these files since the code is # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in: # intended to run in multiple environments; otherwise, check them in:
# .python-version .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@@ -153,3 +150,12 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# quick test
quick.py
#config
config.toml
vban.toml
.vscode/

View File

@@ -11,6 +11,149 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
## [2.4.9] - 2023-08-13
### Added
- Error tests added in tests/test_errors.py
- Errors section in README updated.
### Changed
- VBANCMDConnectionError class now subclasses VBANCMDError
- If the configs loader is passed an invalid config TOML it will log an error but continue to load further configs into memory.
## [2.3.2] - 2023-07-12
### Added
- vban.{instream,outstream} tuples now contain classes that represent MIDI and TEXT streams.
### Fixed
- apply_config() now performs a deep merge when extending a config with another.
## [2.3.0] - 2023-07-11
### Added
- user configs may now extend other user configs. check `config extends` section in README.
## [2.2.0] - 2023-07-08
### Added
- button, vban classes implemented
- \__repr\__() method added to base class
## [2.1.2] - 2023-07-05
### Added
- `outbound` kwarg let's you disable incoming rt packets. Essentially the interface will work only in one direction.
This is useful if you are only interested in sending commands out to voicemeeter but don't need to receive parameter states.
By default outbound is False.
- sendtext logging added in base class.
### Fixed
- Bug in apply() if invoked from a higher class (not base class)
## [2.0.0] - 2023-06-25
This update introduces some breaking changes:
### Changed
- `strip[i].comp` now references StripComp class
- To change the comp knob you should now use the property `strip[i].comp.knob`
- `strip[i].gate` now references StripGate class
- To change the gate knob you should now use the property `strip[i].gate.knob`
- `bus[i].eq` now references BusEQ class
- To set bus[i].{eq,eq_ab} as before you should now use bus[i].eq.on and bus[i].eq.ab
- new error class `VBANCMDConnectionError` raised when a connection fails or times out.
There are other non-breaking changes:
### Changed
- now using a producer thread to send events to the updater thread.
- factory.request_vbancmd_obj simply raises a `VBANCMDError` if passed an incorrect kind.
- module level loggers implemented (with class loggers as child loggers)
### Added
- `strip[i].eq` added to PhysicalStrip
## [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 ## [1.3.0] - 2022-08-02
### Added ### Added

356
README.md
View File

@@ -8,7 +8,7 @@
# VBAN CMD # VBAN CMD
This python interface allows you to get and set Voicemeeter parameter values over a network. This python interface allows you to transmit Voicemeeter parameters 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 may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python)
@@ -18,31 +18,42 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Tested against ## Tested against
- Basic 1.0.8.4 - Basic 1.0.8.8
- Banana 2.0.6.4 - Banana 2.0.6.8
- Potato 3.0.2.4 - Potato 3.0.2.8
## Requirements ## Requirements
- [Voicemeeter](https://voicemeeter.com/) - [Voicemeeter](https://voicemeeter.com/)
- Python 3.11 or greater - Python 3.10 or greater
## Installation ## Installation
### `Pip`
Install vban-cmd package from your console
`pip install vban-cmd` `pip install vban-cmd`
## `Use` ## `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 in \<user home directory\> / "Documents" / "Voicemeeter" / "configs"
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. 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. Login and logout are handled for you in this scenario.
#### `__main__.py`
```python ```python
import vban_cmd import vban_cmd
@@ -59,17 +70,21 @@ class ManyThings:
) )
def other_things(self): def other_things(self):
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq.on = True
info = ( info = (
f"bus 3 gain has been set to {self.vban.bus[3].gain}", 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}", f"bus 4 eq has been set to {self.vban.bus[4].eq.on}",
) )
self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True
print("\n".join(info)) print("\n".join(info))
def main(): 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 = ManyThings(vban)
do.things() do.things()
do.other_things() do.other_things()
@@ -78,27 +93,21 @@ def main():
vban.apply( vban.apply(
{ {
"strip-2": {"A1": True, "B1": True, "gain": -6.0}, "strip-2": {"A1": True, "B1": True, "gain": -6.0},
"bus-2": {"mute": True}, "bus-2": {"mute": True, "eq": {"on": True}},
"vban-in-0": {"on": True},
} }
) )
if __name__ == "__main__": if __name__ == "__main__":
kind_id = "banana"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main() main()
``` ```
Otherwise you must remember to call `vban.login()`, `vban.logout()` at the start/end of your code. Otherwise you must remember to call `vban.login()`, `vban.logout()` at the start/end of your code.
## `kind_id` ## `KIND_ID`
Pass the kind of Voicemeeter as an argument. kind_id may be: Pass the kind of Voicemeeter as an argument. KIND_ID may be:
- `basic` - `basic`
- `banana` - `banana`
@@ -116,17 +125,95 @@ The following properties are available.
- `label`: string - `label`: string
- `gain`: float, -60 to 12 - `gain`: float, -60 to 12
- `A1 - A5`, `B1 - B3`: boolean - `A1 - A5`, `B1 - B3`: boolean
- `comp`: float, from 0.0 to 10.0
- `gate`: float, from 0.0 to 10.0
- `limit`: int, from -40 to 12 - `limit`: int, from -40 to 12
example: example:
```python ```python
vban.strip[3].gain = 3.7 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)
```
##### Strip.Comp
The following properties are available.
- `knob`: float, from 0.0 to 10.0
- `gainin`: float, from -24.0 to 24.0
- `ratio`: float, from 1.0 to 8.0
- `threshold`: float, from -40.0 to -3.0
- `attack`: float, from 0.0 to 200.0
- `release`: float, from 0.0 to 5000.0
- `knee`: float, from 0.0 to 1.0
- `gainout`: float, from -24.0 to 24.0
- `makeup`: boolean
example:
```python
print(vban.strip[4].comp.knob)
```
Strip Comp properties are defined as write only.
`knob` defined for all versions, all other parameters potato only.
##### Strip.Gate
The following properties are available.
- `knob`: float, from 0.0 to 10.0
- `threshold`: float, from -60.0 to -10.0
- `damping`: float, from -60.0 to -10.0
- `bpsidechain`: int, from 100 to 4000
- `attack`: float, from 0.0 to 1000.0
- `hold`: float, from 0.0 to 5000.0
- `release`: float, from 0.0 to 5000.0
example:
```python
vban.strip[2].gate.attack = 300.8
```
Strip Gate properties are defined as write only, potato version only.
`knob` defined for all versions, all other parameters potato only.
##### Strip.Denoiser
The following properties are available.
- `knob`: float, from 0.0 to 10.0
strip.denoiser properties are defined as write only, potato version only.
##### Strip.EQ
The following properties are available.
- `on`: boolean
- `ab`: boolean
Strip EQ properties are defined as write only, potato version only.
##### Gainlayers ##### Gainlayers
- `gain`: float, from -60.0 to 12.0 - `gain`: float, from -60.0 to 12.0
@@ -158,8 +245,6 @@ Level properties will return -200.0 if no audio detected.
The following properties are available. The following properties are available.
- `mono`: boolean - `mono`: boolean
- `eq`: boolean
- `eq_ab`: boolean
- `mute`: boolean - `mute`: boolean
- `label`: string - `label`: string
- `gain`: float, -60 to 12 - `gain`: float, -60 to 12
@@ -167,10 +252,20 @@ The following properties are available.
example: example:
```python ```python
vban.bus[4].eq = true
print(vban.bus[0].label) print(vban.bus[0].label)
``` ```
##### Bus.EQ
The following properties are available.
- `on`: boolean
- `ab`: boolean
```python
vban.bus[4].eq.on = true
```
##### Modes ##### Modes
The following properties are available. The following properties are available.
@@ -213,6 +308,22 @@ print(vban.bus[0].levels.all)
`levels.all` will return -200.0 if no audio detected. `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 ### 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:
@@ -242,8 +353,10 @@ vban.command.showvbanchat = true
```python ```python
vban.apply( vban.apply(
{ {
"strip-2": {"A1": True, "B1": True, "gain": -6.0}, "strip-0": {"A1": True, "B1": True, "gain": -6.0},
"bus-2": {"mute": True}, "bus-1": {"mute": True, "mode": "composite"},
"bus-2": {"eq": {"on": True}},
"vban-in-0": {"on": True},
} }
) )
``` ```
@@ -251,8 +364,8 @@ vban.apply(
Or for each class you may do: Or for each class you may do:
```python ```python
vban.strip[0].apply(mute: true, gain: 3.2, A1: true) vban.strip[0].apply({"mute": True, "gain": 3.2, "A1": True})
vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24) vban.vban.outstream[0].apply({"on": True, "name": "streamname", "bit": 24})
``` ```
## Config Files ## Config Files
@@ -261,7 +374,7 @@ vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24)
You may load config files in TOML format. You may load config files in TOML format.
Three example configs have been included with the package. Remember to save Three example configs have been included with the package. Remember to save
current settings before loading a user config. To set one you may do: current settings before loading a user config. To load one you may do:
```python ```python
import vban_cmd import vban_cmd
@@ -271,63 +384,110 @@ with vban_cmd.api('banana') as vban:
will load a config file at configs/banana/example.toml for Voicemeeter Banana. will load a config file at configs/banana/example.toml for Voicemeeter Banana.
## `Base Module` Your configs may be located in one of the following paths:
- \<current working directory\> / "configs" / kind_id
- \<user home directory\> / ".config" / "vban-cmd" / kind_id
- \<user home directory\> / "Documents" / "Voicemeeter" / "configs" / kind_id
### VbanCmd class If a config with the same name is located in multiple locations, only the first one found is loaded into memory, in the above order.
`vban_cmd.api(kind_id: str, **opts: dict)` #### `config extends`
You may also load a config that extends another config with overrides or additional parameters.
You just need to define a key `extends` in the config TOML, that names the config to be extended.
Three example 'extender' configs are included with the repo. You may load them with:
```python
import voicemeeterlib
with voicemeeterlib.api('banana') as vm:
vm.apply_config('extender')
```
## Events
Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
example:
```python
import vban_cmd
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
with vban_cmd.api('banana', ldirty=True, **opts) as vban:
...
```
#### `vban.subject`
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
# register an app to receive updates
class App():
def __init__(self, vban):
vban.subject.add(self)
...
```
#### `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)`
You may pass the following optional keyword arguments: You may pass the following optional keyword arguments:
- `ip`: str, ip or hostname of remote machine - `ip`: str, ip or hostname of remote machine
- `streamname`: str, name of the stream to connect to. - `streamname`: str, name of the stream to connect to.
- `port`: int=6980, vban udp port of remote machine. - `port`: int=6980, vban udp port of remote machine.
- `subs`: dict={"pdirty": True, "ldirty": False}, controls which updates to listen for. - `pdirty`: boolean=False, parameter updates
- `pdirty`: parameter updates - `ldirty`: boolean=False, level updates
- `ldirty`: level updates - `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)
#### 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.
example:
```python
import vban_cmd
# Listen for level updates
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
"subs": {"ldirty": True},
}
with vban_cmd.api('banana', **opts) as vban:
...
```
#### `vban.event`
You may also add/remove event subscriptions as necessary with the Event class.
example:
```python
vban.event.add("ldirty")
vban.event.remove("pdirty")
# get a list of currently subscribed
print(vban.event.get())
```
#### `vban.pdirty` #### `vban.pdirty`
@@ -347,13 +507,31 @@ vban.sendtext("Strip[0].Mute=1;Bus[0].Mono=1")
#### `vban.public_packet` #### `vban.public_packet`
Returns a Voicemeeter rt data packet object. Designed to be used internally by the interface but available for parsing through this read only property object. States not guaranteed to be current (requires use of dirty parameters to confirm). Returns a `VbanRtPacket`. Designed to be used internally by the interface but available for parsing through this read only property object.
### `Errors` States not guaranteed to be current (requires use of dirty parameters to confirm).
- `errors.VMCMDErrors`: Base VMCMD error class. ## Errors
### `Tests` - `errors.VBANCMDError`: Exception raised when general errors occur.
- `errors.VBANCMDConnectionError`: Exception raised when connection/timeout errors occur.
## Logging
It's possible to see the messages sent by the interface's setters and getters, may be useful for debugging.
example:
```python
import vban_cmd
logging.basicConfig(level=logging.DEBUG)
opts = {"ip": "ip.local", "port": 6980, "streamname": "Command1"}
with vban_cmd.api('banana', **opts) as vban:
...
```
## Tests
First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/vban-cmd-python#installation) First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/vban-cmd-python#installation)

View File

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

View File

@@ -2,12 +2,12 @@
label = "PhysStrip0" label = "PhysStrip0"
A1 = true A1 = true
gain = -8.8 gain = -8.8
comp = 3.2 comp.knob = 3.2
[strip-1] [strip-1]
label = "PhysStrip1" label = "PhysStrip1"
B1 = true B1 = true
gate = 4.1 gate.knob = 4.1
[strip-2] [strip-2]
label = "PhysStrip2" label = "PhysStrip2"
@@ -31,12 +31,12 @@ mono = true
[bus-2] [bus-2]
label = "PhysBus2" label = "PhysBus2"
eq = true eq.on = true
mode = "composite" mode = "composite"
[bus-3] [bus-3]
label = "VirtBus0" label = "VirtBus0"
eq_ab = true eq.ab = true
mode = "upmix61" mode = "upmix61"
[bus-4] [bus-4]

View File

@@ -0,0 +1,12 @@
extends = "example"
[strip-0]
label = "strip0_extended"
A1 = false
gain = 0.0
[bus-0]
label = "bus0_extended"
mute = false
[vban-in-3]
name = "vban_extended"

View File

@@ -0,0 +1,12 @@
extends = "example"
[strip-0]
label = "strip0_extended"
A1 = false
gain = 0.0
[bus-0]
label = "bus0_extended"
mute = false
[vban-in-3]
name = "vban_extended"

View File

@@ -2,12 +2,12 @@
label = "PhysStrip0" label = "PhysStrip0"
A1 = true A1 = true
gain = -8.8 gain = -8.8
comp = 3.2 comp.knob = 3.2
[strip-1] [strip-1]
label = "PhysStrip1" label = "PhysStrip1"
B1 = true B1 = true
gate = 4.1 gate.knob = 4.1
[strip-2] [strip-2]
label = "PhysStrip2" label = "PhysStrip2"
@@ -47,7 +47,7 @@ mono = true
[bus-2] [bus-2]
label = "PhysBus2" label = "PhysBus2"
eq = true eq.on = true
[bus-3] [bus-3]
label = "PhysBus3" label = "PhysBus3"
@@ -59,7 +59,7 @@ mode = "composite"
[bus-5] [bus-5]
label = "VirtBus0" label = "VirtBus0"
eq_ab = true eq.ab = true
[bus-6] [bus-6]
label = "VirtBus1" label = "VirtBus1"

View File

@@ -0,0 +1,12 @@
extends = "example"
[strip-0]
label = "strip0_extended"
A1 = false
gain = 0.0
[bus-0]
label = "bus0_extended"
mute = false
[vban-in-3]
name = "vban_extended"

13
examples/gui/README.md Normal file
View File

@@ -0,0 +1,13 @@
## About
A single channel GUI demonstrating controls for the first virtual strip if Voicemeeter Banana.
This example demonstrates (to an extent) two way communication.
- Sending parameters values to the Voicemeeter driver.
- Receiving level updates
Parameter updates (pdirty) events are not being received so changing a UI element on the main Voicemeeter app will not be reflected in the example GUI.
## Use
Simply run the script and try the controls.

109
examples/gui/__main__.py Normal file
View File

@@ -0,0 +1,109 @@
import logging
import vban_cmd
logging.basicConfig(level=logging.DEBUG)
import tkinter as tk
from tkinter import ttk
class App(tk.Tk):
INDEX = 3
def __init__(self, vban):
super().__init__()
self.vban = vban
self.title(f"{vban} - version {vban.version}")
self.vban.observer.add(self.on_ldirty)
# create widget variables
self.button_var = tk.BooleanVar(value=vban.strip[self.INDEX].mute)
self.slider_var = tk.DoubleVar(value=vban.strip[self.INDEX].gain)
self.meter_var = tk.DoubleVar(value=self._get_level())
self.gainlabel_var = tk.StringVar(value=self.slider_var.get())
# initialize style table
self.style = ttk.Style()
self.style.theme_use("clam")
self.style.configure(
"Mute.TButton",
foreground="#cd5c5c" if vban.strip[self.INDEX].mute else "#5a5a5a",
)
# create labelframe and grid it onto the mainframe
self.labelframe = tk.LabelFrame(text=self.vban.strip[self.INDEX].label)
self.labelframe.grid(padx=1)
# create slider and grid it onto the labelframe
slider = ttk.Scale(
self.labelframe,
from_=12,
to_=-60,
orient="vertical",
variable=self.slider_var,
command=lambda arg: self.on_slider_move(arg),
)
slider.grid(
column=0,
row=0,
)
slider.bind("<Double-Button-1>", self.on_button_double_click)
# create level meter and grid it onto the labelframe
level_meter = ttk.Progressbar(
self.labelframe,
orient="vertical",
variable=self.meter_var,
maximum=72,
mode="determinate",
)
level_meter.grid(column=1, row=0)
# create gainlabel and grid it onto the labelframe
gainlabel = ttk.Label(self.labelframe, textvariable=self.gainlabel_var)
gainlabel.grid(column=0, row=1, columnspan=2)
# create button and grid it onto the labelframe
button = ttk.Button(
self.labelframe,
text="Mute",
style="Mute.TButton",
command=lambda: self.on_button_press(),
)
button.grid(column=0, row=2, columnspan=2, padx=1, pady=2)
# define callbacks
def on_slider_move(self, *args):
val = round(self.slider_var.get(), 1)
self.vban.strip[self.INDEX].gain = val
self.gainlabel_var.set(val)
def on_button_press(self):
self.button_var.set(not self.button_var.get())
self.vban.strip[self.INDEX].mute = self.button_var.get()
self.style.configure(
"Mute.TButton", foreground="#cd5c5c" if self.button_var.get() else "#5a5a5a"
)
def on_button_double_click(self, e):
self.slider_var.set(0)
self.gainlabel_var.set(0)
self.vban.strip[self.INDEX].gain = 0
def _get_level(self):
val = max(self.vban.strip[self.INDEX].levels.prefader)
return 0 if self.button_var.get() else 72 + val - 12 + self.slider_var.get()
def on_ldirty(self):
self.meter_var.set(self._get_level())
def main():
with vban_cmd.api("banana", ldirty=True) as vban:
app = App(vban)
app.mainloop()
if __name__ == "__main__":
main()

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

@@ -0,0 +1,53 @@
## 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.
Closing OBS will end the script.
## Notes
All but `vban_cmd.iremote` logs are filtered out. Log in DEBUG mode.
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+.

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

@@ -0,0 +1,91 @@
import time
from logging import config
import obsws_python as obsws
import vban_cmd
config.dictConfig(
{
"version": 1,
"formatters": {
"standard": {
"format": "%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s"
}
},
"handlers": {
"stream": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "standard",
}
},
"loggers": {"vban_cmd.iremote": {"handlers": ["stream"], "level": "DEBUG"}},
}
)
class Observer:
def __init__(self, vban):
self.vban = vban
self.client = obsws.EventClient()
self.client.callback.register(
(
self.on_current_program_scene_changed,
self.on_exit_started,
)
)
self.is_running = True
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 on_exit_started(self, _):
self.client.unsubscribe()
self.is_running = False
def main():
with vban_cmd.api("potato") as vban:
observer = Observer(vban)
while observer.is_running:
time.sleep(0.1)
if __name__ == "__main__":
main()

7
examples/obs/setup.py Normal file
View File

@@ -0,0 +1,7 @@
from setuptools import setup
setup(
name="obs",
description="OBS Example",
install_requires=["obsws-python"],
)

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

236
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]] [[package]]
name = "attrs" name = "attrs"
version = "22.1.0" version = "22.1.0"
@@ -22,7 +14,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
[[package]] [[package]]
name = "black" name = "black"
version = "22.6.0" version = "22.8.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev" category = "dev"
optional = false optional = false
@@ -33,6 +25,7 @@ click = ">=8.0.0"
mypy-extensions = ">=0.4.3" mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0" pathspec = ">=0.9.0"
platformdirs = ">=2" platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
[package.extras] [package.extras]
colorama = ["colorama (>=0.4.3)"] colorama = ["colorama (>=0.4.3)"]
@@ -40,6 +33,22 @@ d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"] uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cachetools"
version = "5.3.1"
description = "Extensible memoizing collections and decorators"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "chardet"
version = "5.1.0"
description = "Universal encoding detector for Python 3"
category = "dev"
optional = false
python-versions = ">=3.7"
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.3" version = "8.1.3"
@@ -53,11 +62,31 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.5" version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
[[package]]
name = "distlib"
version = "0.3.6"
description = "Distribution utilities"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "filelock"
version = "3.12.2"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
@@ -91,34 +120,31 @@ python-versions = "*"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "21.3" version = "23.1"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.7"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.9.0" version = "0.10.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
category = "dev" category = "dev"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" python-versions = ">=3.7"
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.5.2" version = "3.7.0"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.extras] [package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.1)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
@@ -141,26 +167,30 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]] [[package]]
name = "pyparsing" name = "pyproject-api"
version = "3.0.9" version = "1.5.2"
description = "pyparsing module - Classes and methods to define and execute parsing grammars" description = "API to interact with the python pyproject.toml based projects"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.6.8" python-versions = ">=3.7"
[package.dependencies]
packaging = ">=23.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
diagrams = ["railroad-diagrams", "jinja2"] docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "wheel (>=0.40)"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.1.2" version = "7.1.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0" attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*" iniconfig = "*"
@@ -198,97 +228,77 @@ pytest = ">=3.6"
name = "tomli" name = "tomli"
version = "2.0.1" version = "2.0.1"
description = "A lil' TOML parser" description = "A lil' TOML parser"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "tox"
version = "4.6.3"
description = "tox is a generic virtualenv management and test command line tool"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies]
cachetools = ">=5.3.1"
chardet = ">=5.1"
colorama = ">=0.4.6"
filelock = ">=3.12.2"
packaging = ">=23.1"
platformdirs = ">=3.5.3"
pluggy = ">=1"
pyproject-api = ">=1.5.2"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
virtualenv = ">=20.23.1"
[package.extras]
docs = ["furo (>=2023.5.20)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "pytest (>=7.3.2)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"]
[[package]]
name = "virtualenv"
version = "20.23.1"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
distlib = ">=0.3.6,<1"
filelock = ">=3.12,<4"
platformdirs = ">=3.5.1,<4"
[package.extras]
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage-enable-subprocess (>=1)", "coverage (>=7.2.7)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.11" python-versions = "^3.10"
content-hash = "13366a58ff2f3fa0de2cb1e3de2f66fff612610fa66bb909201ebaa434cce014" content-hash = "5d0edd070ea010edb4e2ade88dc37324b8b4b04f22db78e49db161185365849b"
[metadata.files] [metadata.files]
atomicwrites = []
attrs = [] attrs = []
black = [ black = []
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, cachetools = []
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, chardet = []
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, click = []
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, colorama = []
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, distlib = []
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, filelock = []
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, iniconfig = []
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, isort = []
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, mypy-extensions = []
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, packaging = []
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, pathspec = []
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, platformdirs = []
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, pluggy = []
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, py = []
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, pyproject-api = []
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, pytest = []
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, pytest-randomly = []
{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"},
]
pytest-repeat = [] pytest-repeat = []
tomli = [ tomli = []
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, tox = []
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, virtualenv = []
]

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vban-cmd" name = "vban-cmd"
version = "1.3.2" version = "2.4.9"
description = "Python interface for the VBAN RT Packet Service (Sendtext)" description = "Python interface for the VBAN RT Packet Service (Sendtext)"
authors = ["onyx-and-iris <code@onyxandiris.online>"] authors = ["onyx-and-iris <code@onyxandiris.online>"]
license = "MIT" license = "MIT"
@@ -8,7 +8,8 @@ readme = "README.md"
repository = "https://github.com/onyx-and-iris/vban-cmd-python" repository = "https://github.com/onyx-and-iris/vban-cmd-python"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.10"
tomli = { version = "^2.0.1", python = "<3.11" }
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
@@ -17,7 +18,29 @@ pytest-randomly = "^3.12.0"
pytest-repeat = "^0.9.1" pytest-repeat = "^0.9.1"
black = "^22.3.0" black = "^22.3.0"
isort = "^5.10.1" isort = "^5.10.1"
tox = "^4.6.3"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
gui = "scripts:ex_gui"
obs = "scripts:ex_obs"
observer = "scripts:ex_observer"
basic = "scripts:test_basic"
banana = "scripts:test_banana"
potato = "scripts:test_potato"
all = "scripts:test_all"
[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py310,py311
[testenv]
allowlist_externals = poetry
commands =
poetry install -v
poetry run pytest tests/
"""

39
scripts.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import subprocess
import sys
from pathlib import Path
def ex_gui():
scriptpath = Path.cwd() / "examples" / "gui" / "."
subprocess.run([sys.executable, str(scriptpath)])
def ex_obs():
scriptpath = Path.cwd() / "examples" / "obs" / "."
subprocess.run([sys.executable, str(scriptpath)])
def ex_observer():
scriptpath = Path.cwd() / "examples" / "observer" / "."
subprocess.run([sys.executable, str(scriptpath)])
def test_basic():
os.environ["KIND"] = "basic"
subprocess.run(["tox"])
def test_banana():
os.environ["KIND"] = "banana"
subprocess.run(["tox"])
def test_potato():
os.environ["KIND"] = "potato"
subprocess.run(["tox"])
def test_all():
steps = [test_basic, test_banana, test_potato]
[step() for step in steps]

View File

@@ -1,25 +1,26 @@
import os
import random import random
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
import vban_cmd import vban_cmd
from vban_cmd.kinds import KindId, kinds_all from vban_cmd.kinds import KindId
from vban_cmd.kinds import request_kind_map as kindmap from vban_cmd.kinds import request_kind_map as kindmap
# let's keep things random # get KIND_ID from env var, otherwise set to random
kind_id = random.choice(tuple(kind_id.name.lower() for kind_id in KindId)) KIND_ID = os.environ.get(
"KIND", random.choice(tuple(kind_id.name.lower() for kind_id in KindId))
)
opts = { opts = {
"ip": "ws.local", "ip": "testing.local",
"streamname": "workstation", "streamname": "testing",
"port": 6990, "port": 6990,
"bps": 0, "bps": 0,
"sync": True,
} }
vbans = {kind.name: vban_cmd.api(kind.name, **opts) for kind in kinds_all} vban = vban_cmd.api(KIND_ID, **opts)
tests = vbans[kind_id] kind = kindmap(KIND_ID)
kind = kindmap(kind_id)
@dataclass @dataclass
@@ -42,9 +43,9 @@ data = Data()
def setup_module(): def setup_module():
print(f"\nRunning tests for kind [{data.name}]\n", file=sys.stdout) print(f"\nRunning tests for kind [{data.name}]\n", file=sys.stdout)
tests.login() vban.login()
tests.command.reset() vban.command.reset()
def teardown_module(): def teardown_module():
tests.logout() vban.logout()

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 46"><title>tests: 46</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">46</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">46</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 49"><title>tests: 49</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">49</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">49</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 48"><title>tests: 48</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">48</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">48</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 51"><title>tests: 51</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">51</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">51</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 52"><title>tests: 52</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">52</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">52</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 59"><title>tests: 59</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">59</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">59</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -11,7 +11,7 @@ Function RunTests {
$line | Tee-Object -FilePath $coverage -Append $line | Tee-Object -FilePath $coverage -Append
} }
} }
Write-Output "$(Get-TimeStamp)" | Out-file $coverage -Append Write-Output "$(Get-TimeStamp)" | Out-File $coverage -Append
Invoke-Expression "genbadge tests -t 90 -i ./tests/.coverage.xml -o ./tests/$kind.svg" Invoke-Expression "genbadge tests -t 90 -i ./tests/.coverage.xml -o ./tests/$kind.svg"
} }
@@ -25,7 +25,10 @@ Function Get-TimeStamp {
if ($MyInvocation.InvocationName -ne ".") { if ($MyInvocation.InvocationName -ne ".") {
Invoke-Expression ".\.venv\Scripts\Activate.ps1" Invoke-Expression ".\.venv\Scripts\Activate.ps1"
@("potato") | ForEach-Object {
$env:KIND = $_
RunTests RunTests
}
Invoke-Expression "deactivate" Invoke-Expression "deactivate"
} }

View File

@@ -2,7 +2,7 @@ import time
import pytest import pytest
from tests import data, tests from tests import data, vban
class TestSetAndGetBoolHigher: class TestSetAndGetBoolHigher:
@@ -12,18 +12,27 @@ class TestSetAndGetBoolHigher:
@classmethod @classmethod
def setup_class(cls): def setup_class(cls):
tests.apply_config("example") vban.apply_config("example")
time.sleep(0.1)
@pytest.mark.skipif(
"not config.getoption('--run-slow')",
reason="Only run when --run-slow is given",
)
def test_it_tests_config_string(self): def test_it_tests_config_string(self):
assert "PhysStrip" in tests.strip[data.phys_in].label assert "PhysStrip" in vban.strip[data.phys_in].label
assert "VirtStrip" in tests.strip[data.virt_in].label assert "VirtStrip" in vban.strip[data.virt_in].label
@pytest.mark.skipif(
"not config.getoption('--run-slow')",
reason="Only run when --run-slow is given",
)
def test_it_tests_config_bool(self): def test_it_tests_config_bool(self):
assert tests.strip[0].A1 == True assert vban.strip[0].A1 == True
@pytest.mark.skipif( @pytest.mark.skipif(
"not config.getoption('--run-slow')", "not config.getoption('--run-slow')",
reason="Only run when --run-slow is given", reason="Only run when --run-slow is given",
) )
def test_it_tests_config_busmode(self): def test_it_tests_config_busmode(self):
assert tests.bus[data.phys_out].mode.get() == "composite" assert vban.bus[data.phys_out].mode.get() == "composite"

37
tests/test_errors.py Normal file
View File

@@ -0,0 +1,37 @@
import re
import pytest
import vban_cmd
from tests import data, vban
class TestErrors:
__test__ = True
def test_it_tests_an_unknown_kind(self):
with pytest.raises(
vban_cmd.error.VBANCMDError,
match=f"Unknown Voicemeeter kind 'unknown_kind'",
):
vban_cmd.api("unknown_kind")
def test_it_tests_an_unknown_config_name(self):
EXPECTED_MSG = "\n".join(
(
f"No config with name 'unknown' is loaded into memory",
f"Known configs: {list(vban.configs.keys())}",
)
)
with pytest.raises(vban_cmd.error.VBANCMDError, match=re.escape(EXPECTED_MSG)):
vban.apply_config("unknown")
def test_it_tests_an_invalid_config_key(self):
CONFIG = {
"strip-0": {"A1": True, "B1": True, "gain": -6.0},
"bus-0": {"mute": True, "eq": {"on": True}},
"unknown-0": {"state": True},
"vban-out-1": {"name": "streamname"},
}
with pytest.raises(ValueError, match="invalid config key 'unknown-0'"):
vban.apply(CONFIG)

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from tests import data, tests from tests import data, vban
class TestRemoteFactories: class TestRemoteFactories:
@@ -11,33 +11,45 @@ class TestRemoteFactories:
reason="Skip test if kind is not basic", reason="Skip test if kind is not basic",
) )
def test_it_tests_remote_attrs_for_basic(self): def test_it_tests_remote_attrs_for_basic(self):
assert hasattr(tests, "strip") assert hasattr(vban, "strip")
assert hasattr(tests, "bus") assert hasattr(vban, "bus")
assert hasattr(tests, "command") assert hasattr(vban, "command")
assert hasattr(vban, "button")
assert hasattr(vban, "vban")
assert len(tests.strip) == 3 assert len(vban.strip) == 3
assert len(tests.bus) == 2 assert len(vban.bus) == 2
assert len(vban.button) == 80
assert len(vban.vban.instream) == 6 and len(vban.vban.outstream) == 5
@pytest.mark.skipif( @pytest.mark.skipif(
data.name != "banana", data.name != "banana",
reason="Skip test if kind is not basic", reason="Skip test if kind is not basic",
) )
def test_it_tests_remote_attrs_for_banana(self): def test_it_tests_remote_attrs_for_banana(self):
assert hasattr(tests, "strip") assert hasattr(vban, "strip")
assert hasattr(tests, "bus") assert hasattr(vban, "bus")
assert hasattr(tests, "command") assert hasattr(vban, "command")
assert hasattr(vban, "button")
assert hasattr(vban, "vban")
assert len(tests.strip) == 5 assert len(vban.strip) == 5
assert len(tests.bus) == 5 assert len(vban.bus) == 5
assert len(vban.button) == 80
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9
@pytest.mark.skipif( @pytest.mark.skipif(
data.name != "potato", data.name != "potato",
reason="Skip test if kind is not basic", reason="Skip test if kind is not basic",
) )
def test_it_tests_remote_attrs_for_potato(self): def test_it_tests_remote_attrs_for_potato(self):
assert hasattr(tests, "strip") assert hasattr(vban, "strip")
assert hasattr(tests, "bus") assert hasattr(vban, "bus")
assert hasattr(tests, "command") assert hasattr(vban, "command")
assert hasattr(vban, "button")
assert hasattr(vban, "vban")
assert len(tests.strip) == 8 assert len(vban.strip) == 8
assert len(tests.bus) == 8 assert len(vban.bus) == 8
assert len(vban.button) == 80
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9

View File

@@ -1,6 +1,6 @@
import pytest import pytest
from tests import data, tests from tests import data, vban
@pytest.mark.parametrize("value", [False, True]) @pytest.mark.parametrize("value", [False, True])
@@ -17,8 +17,8 @@ class TestSetAndGetBoolHigher:
], ],
) )
def test_it_sets_and_gets_strip_bool_params(self, index, param, value): def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
@pytest.mark.skipif( @pytest.mark.skipif(
data.name == "banana", data.name == "banana",
@@ -31,23 +31,22 @@ class TestSetAndGetBoolHigher:
], ],
) )
def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value): def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param", "index,param",
[ [
(data.phys_out, "eq"),
(data.phys_out, "mute"), (data.phys_out, "mute"),
(data.virt_out, "eq_ab"),
(data.virt_out, "sel"), (data.virt_out, "sel"),
], ],
) )
def test_it_sets_and_gets_bus_bool_params(self, index, param, value): def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
setattr(tests.bus[index], param, value) assert hasattr(vban.bus[index], param)
assert getattr(tests.bus[index], param) == value setattr(vban.bus[index], param, value)
assert getattr(vban.bus[index], param) == value
""" bus modes tests, physical and virtual """ """ bus modes tests, physical and virtual """
@@ -66,8 +65,8 @@ class TestSetAndGetBoolHigher:
# here it only makes sense to set/get bus modes as True # here it only makes sense to set/get bus modes as True
if not value: if not value:
value = True value = True
setattr(tests.bus[index].mode, param, value) setattr(vban.bus[index].mode, param, value)
assert getattr(tests.bus[index].mode, param) == value assert getattr(vban.bus[index].mode, param) == value
""" command tests """ """ command tests """
@@ -76,7 +75,7 @@ class TestSetAndGetBoolHigher:
[("lock")], [("lock")],
) )
def test_it_sets_command_bool_params(self, param, value): def test_it_sets_command_bool_params(self, param, value):
setattr(tests.command, param, value) setattr(vban.command, param, value)
class TestSetAndGetIntHigher: class TestSetAndGetIntHigher:
@@ -94,8 +93,8 @@ class TestSetAndGetIntHigher:
], ],
) )
def test_it_sets_and_gets_strip_bool_params(self, index, param, value): def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
class TestSetAndGetFloatHigher: class TestSetAndGetFloatHigher:
@@ -113,15 +112,15 @@ class TestSetAndGetFloatHigher:
], ],
) )
def test_it_sets_and_gets_strip_float_params(self, index, param, value): def test_it_sets_and_gets_strip_float_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,value", "index,value",
[(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)], [(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
) )
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value): def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
assert len(tests.strip[index].levels.prefader) == value assert len(vban.strip[index].levels.prefader) == value
@pytest.mark.skipif( @pytest.mark.skipif(
data.name != "potato", data.name != "potato",
@@ -137,8 +136,42 @@ class TestSetAndGetFloatHigher:
], ],
) )
def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value): def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value):
tests.strip[index].gainlayer[j].gain = value vban.strip[index].gainlayer[j].gain = value
assert tests.strip[index].gainlayer[j].gain == value assert vban.strip[index].gainlayer[j].gain == value
""" strip tests, physical """
@pytest.mark.skipif(
data.name != "potato",
reason="Only test if logged into Potato version",
)
@pytest.mark.parametrize(
"index, param, value",
[
(data.phys_in, "gainin", -8.6),
(data.phys_in, "knee", 0.24),
],
)
def test_it_sets_strip_comp_params(self, index, param, value):
assert hasattr(vban.strip[index].comp, param)
setattr(vban.strip[index].comp, param, value)
# we can set but not get this value. Not in RT Packet.
@pytest.mark.skipif(
data.name != "potato",
reason="Only test if logged into Potato version",
)
@pytest.mark.parametrize(
"index, param, value",
[
(data.phys_in, "bpsidechain", 120),
(data.phys_in, "hold", 3000),
],
)
def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
assert hasattr(vban.strip[index].gate, param)
setattr(vban.strip[index].gate, param, value)
# we can set but not get this value. Not in RT Packet.
""" strip tests, virtual """ """ strip tests, virtual """
@@ -151,8 +184,8 @@ class TestSetAndGetFloatHigher:
], ],
) )
def test_it_sets_and_gets_strip_eq_params(self, index, param, value): def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@@ -161,15 +194,15 @@ class TestSetAndGetFloatHigher:
[(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)], [(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)],
) )
def test_it_sets_and_gets_bus_float_params(self, index, param, value): def test_it_sets_and_gets_bus_float_params(self, index, param, value):
setattr(tests.bus[index], param, value) setattr(vban.bus[index], param, value)
assert getattr(tests.bus[index], param) == value assert getattr(vban.bus[index], param) == value
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,value", "index,value",
[(data.phys_out, 8), (data.virt_out, 8)], [(data.phys_out, 8), (data.virt_out, 8)],
) )
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value): def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
assert len(tests.bus[index].levels.all) == value assert len(vban.bus[index].levels.all) == value
@pytest.mark.parametrize("value", ["test0", "test1"]) @pytest.mark.parametrize("value", ["test0", "test1"])
@@ -183,8 +216,8 @@ class TestSetAndGetStringHigher:
[(data.phys_in, "label"), (data.virt_in, "label")], [(data.phys_in, "label"), (data.virt_in, "label")],
) )
def test_it_sets_and_gets_strip_string_params(self, index, param, value): def test_it_sets_and_gets_strip_string_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@@ -193,5 +226,5 @@ class TestSetAndGetStringHigher:
[(data.phys_out, "label"), (data.virt_out, "label")], [(data.phys_out, "label"), (data.virt_out, "label")],
) )
def test_it_sets_and_gets_bus_string_params(self, index, param, value): def test_it_sets_and_gets_bus_string_params(self, index, param, value):
setattr(tests.bus[index], param, value) setattr(vban.bus[index], param, value)
assert getattr(tests.bus[index], param) == value assert getattr(vban.bus[index], param) == value

View File

@@ -1,9 +1,7 @@
import time
import pytest import pytest
from vban_cmd import kinds
from tests import data, tests from tests import data, vban
from vban_cmd import kinds
class TestPublicPacketLower: class TestPublicPacketLower:
@@ -12,15 +10,11 @@ class TestPublicPacketLower:
"""Tests for a valid rt data packet""" """Tests for a valid rt data packet"""
def test_it_gets_an_rt_data_packet(self): def test_it_gets_an_rt_data_packet(self):
assert tests.public_packet.voicemeetertype in ( assert vban.public_packet.voicemeetertype in (
kind.name for kind in kinds.kinds_all kind.name for kind in kinds.kinds_all
) )
@pytest.mark.skipif(
"not config.getoption('--run-slow')",
reason="Only run when --run-slow is given",
)
@pytest.mark.parametrize("value", [0, 1]) @pytest.mark.parametrize("value", [0, 1])
class TestSetRT: class TestSetRT:
__test__ = True __test__ = True
@@ -35,7 +29,6 @@ class TestSetRT:
], ],
) )
def test_it_sends_a_text_request(self, kls, index, param, value): def test_it_sends_a_text_request(self, kls, index, param, value):
tests._set_rt(f"{kls}[{index}]", param, value) vban._set_rt(f"{kls}[{index}].{param}", value)
time.sleep(0.02) target = getattr(vban, kls)[index]
target = getattr(tests, kls)[index]
assert getattr(target, param) == bool(value) assert getattr(target, param) == bool(value)

View File

@@ -1,164 +0,0 @@
import socket
import time
from abc import ABCMeta, abstractmethod
from typing import Iterable, NoReturn, Optional, Union
from .misc import Event
from .packet import TextRequestHeader
from .subject import Subject
from .util import Socket, comp, script
from .worker import Subscriber, Updater
class VbanCmd(metaclass=ABCMeta):
"""Base class responsible for communicating over VBAN RT Service"""
DELAY = 0.001
# fmt: off
BPS_OPTS = [
0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
1000000, 1500000, 2000000, 3000000,
]
# fmt: on
def __init__(self, **kwargs):
for attr, val in kwargs.items():
setattr(self, attr, val)
self.text_header = TextRequestHeader(
name=self.streamname,
bps_index=self.BPS_OPTS.index(self.bps),
channel=self.channel,
)
self.socks = tuple(
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in Socket
)
self.subject = Subject()
self.cache = dict()
self.event = Event(self.subs)
@abstractmethod
def __str__(self):
"""Ensure subclasses override str magic method"""
pass
def __enter__(self):
self.login()
return self
def login(self):
"""Starts the subscriber and updater threads"""
self.running = True
self.subscriber = Subscriber(self)
self.subscriber.start()
self.updater = Updater(self)
self.updater.start()
def _set_rt(
self,
id_: str,
param: Optional[str] = None,
val: Optional[Union[int, float]] = None,
):
"""Sends a string request command over a network."""
cmd = id_ if not param else f"{id_}.{param}={val}"
self.socks[Socket.request].sendto(
self.text_header.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")
if param:
self.cache[f"{id_}.{param}"] = val
if self.sync:
time.sleep(0.02)
@script
def sendtext(self, cmd):
"""Sends a multiple parameter string over a network."""
self._set_rt(cmd)
time.sleep(self.DELAY)
@property
def type(self) -> str:
"""Returns the type of Voicemeeter installation."""
return self.public_packet.voicemeetertype
@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}"
@property
def pdirty(self):
"""True iff a parameter has changed"""
return self._pdirty
@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))
@property
def public_packet(self):
return self._public_packet
def clear_dirty(self):
while self.pdirty:
pass
def _get_levels(self, packet) -> Iterable:
"""
returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
strip levels in PREFADER mode.
"""
return (
tuple(val for val in packet.inputlevels),
tuple(val for val in packet.outputlevels),
)
def apply(self, data: dict):
"""
Sets all parameters of a dict
minor delay between each recursion
"""
def param(key):
obj, m2, *rem = key.split("-")
index = int(m2) if m2.isnumeric() else int(*rem)
if obj in ("strip", "bus"):
return getattr(self, obj)[index]
else:
raise ValueError(obj)
[param(key).apply(datum).then_wait() for key, datum in data.items()]
def apply_config(self, name):
"""applies a config from memory"""
error_msg = (
f"No config with name '{name}' is loaded into memory",
f"Known configs: {list(self.configs.keys())}",
)
try:
self.apply(self.configs[name])
print(f"Profile '{name}' applied!")
except KeyError as e:
print(("\n").join(error_msg))
def logout(self):
self.running = False
time.sleep(0.2)
[sock.close() for sock in self.socks]
def __exit__(self, exc_type, exc_value, exc_traceback):
self.logout()

View File

@@ -26,28 +26,48 @@ class Bus(IRemote):
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}]" return f"bus[{self.index}]"
@property @property
def gain(self) -> float: def gain(self) -> float:
def fget(): def fget():
val = self.public_packet.busgain[self.index] val = self.public_packet.busgain[self.index]
if val < 10000: if 0 <= val <= 1200:
return -val return val * 0.01
elif val == ((1 << 16) - 1): return (((1 << 16) - 1) - val) * -0.01
return 0
else:
return ((1 << 16) - 1) - val
val = self.getter("gain") val = self.getter("gain")
if val is None: return round(val if val else fget(), 1)
val = fget() * 0.01
return round(val, 1)
@gain.setter @gain.setter
def gain(self, val: float): def gain(self, val: float):
self.setter("gain", val) 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 BusEQ(IRemote):
@classmethod
def make(cls, remote, index):
BUSEQ_cls = type(
f"BusEQ{remote.kind}",
(cls,),
{
**{param: channel_bool_prop(param) for param in ["on", "ab"]},
},
)
return BUSEQ_cls(remote, index)
@property
def identifier(self) -> str:
return f"bus[{self.index}].eq"
class PhysicalBus(Bus): class PhysicalBus(Bus):
def __str__(self): def __str__(self):
@@ -79,14 +99,24 @@ class BusLevel(IRemote):
def getter(self): def getter(self):
"""Returns a tuple of level values for the channel.""" """Returns a tuple of level values for the channel."""
def fget(i):
return round((((1 << 16) - 1) - i) * -0.01, 1)
if not self._remote.stopped() and self._remote.event.ldirty:
return tuple( return tuple(
round(-i * 0.01, 1) fget(i)
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]] for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]]
) )
return tuple(
fget(i)
for i in self._remote._get_levels(self.public_packet)[1][
self.range[0] : self.range[-1]
]
)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}]" return f"bus[{self.index}]"
@property @property
def all(self) -> tuple: def all(self) -> tuple:
@@ -108,7 +138,7 @@ def _make_bus_mode_mixin():
"""Creates a mixin of Bus Modes.""" """Creates a mixin of Bus Modes."""
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}].mode" return f"bus[{self.index}].mode"
def get(self): def get(self):
time.sleep(0.01) time.sleep(0.01)
@@ -154,11 +184,10 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
f"{BUS_cls.__name__}{remote.kind}", f"{BUS_cls.__name__}{remote.kind}",
(BUS_cls,), (BUS_cls,),
{ {
"eq": BusEQ.make(remote, i),
"levels": BusLevel(remote, i), "levels": BusLevel(remote, i),
"mode": BUSMODEMIXIN_cls(remote, i), "mode": BUSMODEMIXIN_cls(remote, i),
**{param: channel_bool_prop(param) for param in ["mute", "mono"]}, **{param: channel_bool_prop(param) for param in ["mute", "mono"]},
"eq": channel_bool_prop("eq.On"),
"eq_ab": channel_bool_prop("eq.ab"),
"label": channel_label_prop(), "label": channel_label_prop(),
}, },
)(remote, i) )(remote, i)

View File

@@ -1,6 +1,5 @@
from .error import VMCMDErrors
from .iremote import IRemote from .iremote import IRemote
from .meta import action_prop from .meta import action_fn
class Command(IRemote): class Command(IRemote):
@@ -22,17 +21,16 @@ class Command(IRemote):
(cls,), (cls,),
{ {
**{ **{
param: action_prop(param) param: action_fn(param) for param in ["show", "shutdown", "restart"]
for param in ["show", "shutdown", "restart"]
}, },
"hide": action_prop("show", val=0), "hide": action_fn("show", val=0),
}, },
) )
return CMD_cls(remote) return CMD_cls(remote)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return "Command" return "command"
def set_showvbanchat(self, val: bool): def set_showvbanchat(self, val: bool):
self.setter("DialogShow.VBANCHAT", 1 if val else 0) self.setter("DialogShow.VBANCHAT", 1 if val else 0)

View File

@@ -1,10 +1,18 @@
import itertools import itertools
import logging
from pathlib import Path from pathlib import Path
from .error import VBANCMDError
try:
import tomllib import tomllib
except ModuleNotFoundError:
import tomli as tomllib
from .kinds import request_kind_map as kindmap from .kinds import request_kind_map as kindmap
logger = logging.getLogger(__name__)
class TOMLStrBuilder: class TOMLStrBuilder:
"""builds a config profile, as a string, for the toml parser""" """builds a config profile, as a string, for the toml parser"""
@@ -28,10 +36,18 @@ class TOMLStrBuilder:
+ [f"B{i} = false" for i in range(1, self.kind.virt_out + 1)] + [f"B{i} = false" for i in range(1, self.kind.virt_out + 1)]
) )
self.phys_strip_params = self.virt_strip_params + [ self.phys_strip_params = self.virt_strip_params + [
"comp = 0.0", "comp.knob = 0.0",
"gate = 0.0", "gate.knob = 0.0",
"denoiser.knob = 0.0",
"eq.on = false",
]
self.bus_float = ["gain = 0.0"]
self.bus_params = [
"mono = false",
"eq.on = false",
"mute = false",
"gain = 0.0",
] ]
self.bus_bool = ["mono = false", "eq = false", "mute = false"]
if profile == "reset": if profile == "reset":
self.reset_config() self.reset_config()
@@ -62,7 +78,7 @@ class TOMLStrBuilder:
else self.virt_strip_params else self.virt_strip_params
) )
case "bus": case "bus":
toml_str += ("\n").join(self.bus_bool) toml_str += ("\n").join(self.bus_params)
case _: case _:
pass pass
return toml_str + "\n" return toml_str + "\n"
@@ -70,7 +86,6 @@ class TOMLStrBuilder:
class TOMLDataExtractor: class TOMLDataExtractor:
def __init__(self, file): def __init__(self, file):
self._data = dict()
with open(file, "rb") as f: with open(file, "rb") as f:
self._data = tomllib.load(f) self._data = tomllib.load(f)
@@ -118,6 +133,7 @@ class Loader(metaclass=SingletonType):
def __init__(self, kind): def __init__(self, kind):
self._kind = kind self._kind = kind
self.logger = logger.getChild(self.__class__.__name__)
self._configs = dict() self._configs = dict()
self.defaults(kind) self.defaults(kind)
self.parser = None self.parser = None
@@ -129,14 +145,21 @@ class Loader(metaclass=SingletonType):
def parse(self, identifier, data): def parse(self, identifier, data):
if identifier in self._configs: if identifier in self._configs:
print(f"config file with name {identifier} already in memory, skipping..") self.logger.info(
return False f"config file with name {identifier} already in memory, skipping.."
)
return
try:
self.parser = dataextraction_factory(data) self.parser = dataextraction_factory(data)
except tomllib.TOMLDecodeError as e:
ERR_MSG = (str(e), f"When attempting to load {identifier}.toml")
self.logger.error(f"{type(e).__name__}: {' '.join(ERR_MSG)}")
return
return True return True
def register(self, identifier, data=None): def register(self, identifier, data=None):
self._configs[identifier] = data if data else self.parser.data 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): def deregister(self):
self._configs.clear() self._configs.clear()
@@ -159,15 +182,16 @@ def loader(kind):
returns configs loaded into memory returns configs loaded into memory
""" """
logger_loader = logger.getChild("loader")
loader = Loader(kind) loader = Loader(kind)
for path in ( for path in (
Path.cwd() / "configs" / kind.name, Path.cwd() / "configs" / kind.name,
Path(__file__).parent / "configs" / kind.name, Path.home() / ".config" / "vban-cmd" / kind.name,
Path.home() / "Documents/Voicemeeter" / "configs" / kind.name, Path.home() / "Documents" / "Voicemeeter" / "configs" / kind.name,
): ):
if path.is_dir(): if path.is_dir():
print(f"Checking [{path}] for TOML config files:") logger_loader.info(f"Checking [{path}] for TOML config files:")
for file in path.glob("*.toml"): for file in path.glob("*.toml"):
identifier = file.with_suffix("").stem identifier = file.with_suffix("").stem
if loader.parse(identifier, file): if loader.parse(identifier, file):
@@ -183,6 +207,6 @@ def request_config(kind_id: str):
""" """
try: try:
configs = loader(kindmap(kind_id)) configs = loader(kindmap(kind_id))
except KeyError as e: except KeyError:
print(f"Unknown Voicemeeter kind '{kind_id}'") raise VBANCMDError(f"Unknown Voicemeeter kind {kind_id}")
return configs return configs

View File

@@ -1,4 +1,6 @@
class VMCMDErrors(Exception): class VBANCMDError(Exception):
"""general errors""" """Base VBANCMD Exception class. Raised when general errors occur"""
pass
class VBANCMDConnectionError(VBANCMDError):
"""Exception raised when connection/timeout errors occur"""

56
vban_cmd/event.py Normal file
View File

@@ -0,0 +1,56 @@
import logging
from typing import Iterable, Union
logger = logging.getLogger(__name__)
class Event:
"""Keeps track of event subscriptions"""
def __init__(self, subs: dict):
self.subs = subs
self.logger = logger.getChild(self.__class__.__name__)
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,21 @@
import logging
from abc import abstractmethod from abc import abstractmethod
from enum import IntEnum from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import Iterable, NoReturn, Self from typing import Iterable
from .base import VbanCmd
from .bus import request_bus_obj as bus from .bus import request_bus_obj as bus
from .command import Command from .command import Command
from .config import request_config as configs from .config import request_config as configs
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 .strip import request_strip_obj as strip from .strip import request_strip_obj as strip
from .vban import request_vban_obj as vban
from .vbancmd import VbanCmd
logger = logging.getLogger(__name__)
class FactoryBuilder: class FactoryBuilder:
@@ -19,7 +25,9 @@ class FactoryBuilder:
Separates construction from representation. Separates construction from representation.
""" """
BuilderProgress = IntEnum("BuilderProgress", "strip bus command", start=0) BuilderProgress = IntEnum(
"BuilderProgress", "strip bus command macrobutton vban", start=0
)
def __init__(self, factory, kind: KindMapClass): def __init__(self, factory, kind: KindMapClass):
self._factory = factory self._factory = factory
@@ -28,39 +36,47 @@ class FactoryBuilder:
f"Finished building strips for {self._factory}", f"Finished building strips for {self._factory}",
f"Finished building buses for {self._factory}", f"Finished building buses for {self._factory}",
f"Finished building commands for {self._factory}", f"Finished building commands for {self._factory}",
f"Finished building macrobuttons for {self._factory}",
f"Finished building vban in/out streams for {self._factory}",
) )
self.logger = logger.getChild(self.__class__.__name__)
def _pinfo(self, name: str) -> NoReturn: def _pinfo(self, name: str) -> None:
"""prints progress status for each step""" """prints progress status for each step"""
name = name.split("_")[1] 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( self._factory.strip = tuple(
strip(i < self.kind.phys_in, self._factory, i) strip(i < self.kind.phys_in, self._factory, i)
for i in range(self.kind.num_strip) for i in range(self.kind.num_strip)
) )
return self return self
def make_bus(self) -> Self: def make_bus(self):
self._factory.bus = tuple( self._factory.bus = tuple(
bus(i < self.kind.phys_out, self._factory, i) bus(i < self.kind.phys_out, self._factory, i)
for i in range(self.kind.num_bus) for i in range(self.kind.num_bus)
) )
return self return self
def make_command(self) -> Self: def make_command(self):
self._factory.command = Command.make(self._factory) self._factory.command = Command.make(self._factory)
return self return self
def make_macrobutton(self):
self._factory.button = tuple(MacroButton(self._factory, i) for i in range(80))
return self
def make_vban(self):
self._factory.vban = vban(self._factory)
return self
class FactoryBase(VbanCmd): class FactoryBase(VbanCmd):
"""Base class for factories, subclasses VbanCmd.""" """Base class for factories, subclasses VbanCmd."""
def __init__(self, kind_id: str, **kwargs): def __init__(self, kind_id: str, **kwargs):
defaultsubs = {"pdirty": True, "ldirty": False}
if "subs" in kwargs:
defaultsubs = defaultsubs | kwargs.pop("subs")
defaultkwargs = { defaultkwargs = {
"ip": None, "ip": None,
"port": 6980, "port": 6980,
@@ -68,9 +84,14 @@ class FactoryBase(VbanCmd):
"bps": 0, "bps": 0,
"channel": 0, "channel": 0,
"ratelimit": 0.01, "ratelimit": 0.01,
"timeout": 5,
"outbound": False,
"sync": False, "sync": False,
"subs": defaultsubs, "pdirty": False,
"ldirty": False,
} }
if "subs" in kwargs:
defaultkwargs |= kwargs.pop("subs") # for backwards compatibility
kwargs = defaultkwargs | kwargs kwargs = defaultkwargs | kwargs
self.kind = kindmap(kind_id) self.kind = kindmap(kind_id)
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -79,12 +100,20 @@ class FactoryBase(VbanCmd):
self.builder.make_strip, self.builder.make_strip,
self.builder.make_bus, self.builder.make_bus,
self.builder.make_command, self.builder.make_command,
self.builder.make_macrobutton,
self.builder.make_vban,
) )
self._configs = None self._configs = None
def __str__(self) -> str: def __str__(self) -> str:
return f"Voicemeeter {self.kind}" return f"Voicemeeter {self.kind}"
def __repr__(self):
return (
type(self).__name__
+ f"({self.kind}, ip='{self.ip}', port={self.port}, streamname='{self.streamname}')"
)
@property @property
@abstractmethod @abstractmethod
def steps(self): def steps(self):
@@ -186,9 +215,12 @@ def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
Returns a reference to a VbanCmd class of a kind Returns a reference to a VbanCmd class of a kind
""" """
logger_entry = logger.getChild("factory.request_vbancmd_obj")
VBANCMD_obj = None VBANCMD_obj = None
try: try:
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs) VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
raise SystemExit(e) logger_entry.exception(f"{type(e).__name__}: {e}")
raise VBANCMDError(str(e)) from e
return VBANCMD_obj return VBANCMD_obj

View File

@@ -1,7 +1,10 @@
import logging
import time import time
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass @dataclass
class Modes: class Modes:
@@ -26,9 +29,9 @@ class Modes:
_mask: hex = 0x000000F0 _mask: hex = 0x000000F0
_eq_on: hex = 0x00000100 _on: hex = 0x00000100 # eq.on
_cross: hex = 0x00000200 _cross: hex = 0x00000200
_eq_ab: hex = 0x00000800 _ab: hex = 0x00000800 # eq.ab
_busa: hex = 0x00001000 _busa: hex = 0x00001000
_busa1: hex = 0x00001000 _busa1: hex = 0x00001000
@@ -85,17 +88,29 @@ class IRemote(metaclass=ABCMeta):
def __init__(self, remote, index=None): def __init__(self, remote, index=None):
self._remote = remote self._remote = remote
self.index = index self.index = index
self.logger = logger.getChild(self.__class__.__name__)
self._modes = Modes() self._modes = Modes()
def getter(self, param): def getter(self, param):
cmd = f"{self.identifier}.{param}" cmd = self._cmd(param)
self.logger.debug(f"getter: {cmd}")
if cmd in self._remote.cache: if cmd in self._remote.cache:
return self._remote.cache.pop(cmd) return self._remote.cache.pop(cmd)
if self._remote.sync:
self._remote.clear_dirty()
def setter(self, param, val): def setter(self, param, val):
"""Sends a string request RT packet.""" """Sends a string request RT packet."""
self._remote._set_rt(f"{self.identifier}", param, val) self.logger.debug(f"setter: {self._cmd(param)}={val}")
self._remote._set_rt(self._cmd(param), val)
def _cmd(self, param):
cmd = (self.identifier,)
if param:
cmd += (f".{param}",)
return "".join(cmd)
@property
@abstractmethod @abstractmethod
def identifier(self): def identifier(self):
pass pass
@@ -111,20 +126,26 @@ class IRemote(metaclass=ABCMeta):
def fget(attr, val): def fget(attr, val):
if attr == "mode": if attr == "mode":
return (f"mode.{val}", 1) return (f"mode.{val}", 1)
elif attr == "knob":
return ("", val)
return (attr, val) return (attr, val)
script = str()
for attr, val in data.items(): for attr, val in data.items():
if hasattr(self, attr): if not isinstance(val, dict):
if attr in dir(self): # avoid calling getattr (with hasattr)
attr, val = fget(attr, val) attr, val = fget(attr, val)
if isinstance(val, bool): if isinstance(val, bool):
val = 1 if val else 0 val = 1 if val else 0
self._remote.cache[f"{self.identifier}[{self.index}].{attr}"] = val self._remote.cache[self._cmd(attr)] = val
script += f"{self.identifier}[{self.index}].{attr}={val};" self._remote._script += f"{self._cmd(attr)}={val};"
else:
target = getattr(self, attr)
target.apply(val)
self._remote.sendtext(script) self._remote.sendtext(self._remote._script)
return self return self
def then_wait(self): def then_wait(self):
self._remote._script = str()
time.sleep(self._remote.DELAY) time.sleep(self._remote.DELAY)

View File

@@ -1,6 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, unique from enum import Enum, unique
from .error import VBANCMDError
@unique @unique
class KindId(Enum): class KindId(Enum):
@@ -51,6 +53,14 @@ class KindMapClass(metaclass=SingletonType):
def num_bus(self): def num_bus(self):
return sum(self.outs) return sum(self.outs)
@property
def num_strip_levels(self) -> int:
return 2 * self.phys_in + 8 * self.virt_in
@property
def num_bus_levels(self) -> int:
return 8 * (self.phys_out + self.virt_out)
def __str__(self) -> str: def __str__(self) -> str:
return self.name.capitalize() return self.name.capitalize()
@@ -60,7 +70,7 @@ class BasicMap(KindMapClass):
name: str name: str
ins: tuple = (2, 1) ins: tuple = (2, 1)
outs: tuple = (1, 1) outs: tuple = (1, 1)
vban: tuple = (4, 4) vban: tuple = (4, 4, 1, 1)
@dataclass @dataclass
@@ -68,7 +78,7 @@ class BananaMap(KindMapClass):
name: str name: str
ins: tuple = (3, 2) ins: tuple = (3, 2)
outs: tuple = (3, 2) outs: tuple = (3, 2)
vban: tuple = (8, 8) vban: tuple = (8, 8, 1, 1)
@dataclass @dataclass
@@ -76,7 +86,7 @@ class PotatoMap(KindMapClass):
name: str name: str
ins: tuple = (5, 3) ins: tuple = (5, 3)
outs: tuple = (5, 3) outs: tuple = (5, 3)
vban: tuple = (8, 8) vban: tuple = (8, 8, 1, 1)
def kind_factory(kind_id): def kind_factory(kind_id):
@@ -97,7 +107,7 @@ def request_kind_map(kind_id):
try: try:
KIND_obj = kind_factory(kind_id) KIND_obj = kind_factory(kind_id)
except ValueError as e: except ValueError as e:
print(e) raise VBANCMDError(str(e)) from e
return KIND_obj return KIND_obj

36
vban_cmd/macrobutton.py Normal file
View File

@@ -0,0 +1,36 @@
from .iremote import IRemote
class MacroButton(IRemote):
"""A placeholder class in case this interface is being used interchangeably with the Remote API"""
def __str__(self):
return f"{type(self).__name__}{self._remote.kind}{self.index}"
@property
def identifier(self):
return f"command.button[{self.index}]"
@property
def state(self) -> bool:
self.logger.warning("button.state commands are not supported over VBAN")
@state.setter
def state(self, _):
self.logger.warning("button.state commands are not supported over VBAN")
@property
def stateonly(self) -> bool:
self.logger.warning("button.stateonly commands are not supported over VBAN")
@stateonly.setter
def stateonly(self, v):
self.logger.warning("button.stateonly commands are not supported over VBAN")
@property
def trigger(self) -> bool:
self.logger.warning("button.trigger commands are not supported over VBAN")
@trigger.setter
def trigger(self, _):
self.logger.warning("button.trigger commands are not supported over VBAN")

View File

@@ -1,6 +1,5 @@
from functools import partial from functools import partial
from .error import VMCMDErrors
from .util import cache_bool, cache_string from .util import cache_bool, cache_string
@@ -17,7 +16,7 @@ def channel_bool_prop(param):
)[self.index], )[self.index],
"little", "little",
) )
& getattr(self._modes, f'_{param.replace(".", "_").lower()}') & getattr(self._modes, f"_{param.lower()}")
== 0 == 0
) )
@@ -92,8 +91,8 @@ def bus_mode_prop(param):
return property(fget, fset) return property(fget, fset)
def action_prop(param, val=1): def action_fn(param, val=1):
"""A param that performs an action""" """A function that performs an action"""
def fdo(self): def fdo(self):
self.setter(param, val) self.setter(param, val)

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,47 +1,66 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generator
from .kinds import KindMapClass
from .util import comp
VBAN_PROTOCOL_TXT = 0x40
VBAN_PROTOCOL_SERVICE = 0x60
VBAN_SERVICE_RTPACKETREGISTER = 32 VBAN_SERVICE_RTPACKETREGISTER = 32
VBAN_SERVICE_RTPACKET = 33 VBAN_SERVICE_RTPACKET = 33
MAX_PACKET_SIZE = 1436 MAX_PACKET_SIZE = 1436
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 + 4 HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
@dataclass @dataclass
class VBAN_VMRT_Packet_Data: class VbanRtPacket:
"""Represents the structure of a VMRT data packet""" """Represents the body of a VBAN RT data packet"""
_voicemeeterType: bytes _kind: KindMapClass
_reserved: bytes _voicemeeterType: bytes # data[28:29]
_buffersize: bytes _reserved: bytes # data[29:30]
_voicemeeterVersion: bytes _buffersize: bytes # data[30:32]
_optionBits: bytes _voicemeeterVersion: bytes # data[32:36]
_samplerate: bytes _optionBits: bytes # data[36:40]
_inputLeveldB100: bytes _samplerate: bytes # data[40:44]
_outputLeveldB100: bytes _inputLeveldB100: bytes # data[44:112]
_TransportBit: bytes _outputLeveldB100: bytes # data[112:240]
_stripState: bytes _TransportBit: bytes # data[240:244]
_busState: bytes _stripState: bytes # data[244:276]
_stripGaindB100Layer1: bytes _busState: bytes # data[276:308]
_stripGaindB100Layer2: bytes _stripGaindB100Layer1: bytes # data[308:324]
_stripGaindB100Layer3: bytes _stripGaindB100Layer2: bytes # data[324:340]
_stripGaindB100Layer4: bytes _stripGaindB100Layer3: bytes # data[340:356]
_stripGaindB100Layer5: bytes _stripGaindB100Layer4: bytes # data[356:372]
_stripGaindB100Layer6: bytes _stripGaindB100Layer5: bytes # data[372:388]
_stripGaindB100Layer7: bytes _stripGaindB100Layer6: bytes # data[388:404]
_stripGaindB100Layer8: bytes _stripGaindB100Layer7: bytes # data[404:420]
_busGaindB100: bytes _stripGaindB100Layer8: bytes # data[420:436]
_stripLabelUTF8c60: bytes _busGaindB100: bytes # data[436:452]
_busLabelUTF8c60: bytes _stripLabelUTF8c60: bytes # data[452:932]
_busLabelUTF8c60: bytes # data[932:1412]
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)
)
@property
def strip_levels(self):
return self._generate_levels(self._inputLeveldB100)
@property
def bus_levels(self):
return self._generate_levels(self._outputLeveldB100)
def pdirty(self, other) -> bool:
"""True iff any defined parameter has changed""" """True iff any defined parameter has changed"""
return not ( return not (
self._stripState == other._stripState self._stripState == other._stripState
and self._busState == other._busState and self._busState == other._busState
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
and self._busLabelUTF8c60 == other._busLabelUTF8c60
and self._stripGaindB100Layer1 == other._stripGaindB100Layer1 and self._stripGaindB100Layer1 == other._stripGaindB100Layer1
and self._stripGaindB100Layer2 == other._stripGaindB100Layer2 and self._stripGaindB100Layer2 == other._stripGaindB100Layer2
and self._stripGaindB100Layer3 == other._stripGaindB100Layer3 and self._stripGaindB100Layer3 == other._stripGaindB100Layer3
@@ -51,8 +70,17 @@ class VBAN_VMRT_Packet_Data:
and self._stripGaindB100Layer7 == other._stripGaindB100Layer7 and self._stripGaindB100Layer7 == other._stripGaindB100Layer7
and self._stripGaindB100Layer8 == other._stripGaindB100Layer8 and self._stripGaindB100Layer8 == other._stripGaindB100Layer8
and self._busGaindB100 == other._busGaindB100 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_levels)),
tuple(not val for val in comp(bus_cache, self.bus_levels)),
)
return any(any(l) for l in (self._strip_comp, self._bus_comp))
@property @property
def voicemeetertype(self) -> str: def voicemeetertype(self) -> str:
"""returns voicemeeter type as a string""" """returns voicemeeter type as a string"""
@@ -77,24 +105,14 @@ class VBAN_VMRT_Packet_Data:
return int.from_bytes(self._samplerate, "little") return int.from_bytes(self._samplerate, "little")
@property @property
def inputlevels(self) -> Generator[float, None, None]: def inputlevels(self) -> tuple:
"""returns the entire level array across all inputs""" """returns the entire level array across all inputs for a kind"""
for i in range(0, 68, 2): return self.strip_levels[0 : self._kind.num_strip_levels]
val = ((1 << 16) - 1) - int.from_bytes(
self._inputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
@property @property
def outputlevels(self) -> Generator[float, None, None]: def outputlevels(self) -> tuple:
"""returns the entire level array across all outputs""" """returns the entire level array across all outputs for a kind"""
for i in range(0, 128, 2): return self.bus_levels[0 : self._kind.num_bus_levels]
val = ((1 << 16) - 1) - int.from_bytes(
self._outputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
@property @property
def stripstate(self) -> tuple: def stripstate(self) -> tuple:
@@ -114,64 +132,56 @@ class VBAN_VMRT_Packet_Data:
@property @property
def stripgainlayer1(self) -> tuple: def stripgainlayer1(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer2(self) -> tuple: def stripgainlayer2(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer3(self) -> tuple: def stripgainlayer3(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer4(self) -> tuple: def stripgainlayer4(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer5(self) -> tuple: def stripgainlayer5(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer6(self) -> tuple: def stripgainlayer6(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer7(self) -> tuple: def stripgainlayer7(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@property @property
def stripgainlayer8(self) -> tuple: def stripgainlayer8(self) -> tuple:
return 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) for i in range(0, 16, 2)
) )
@@ -179,7 +189,7 @@ class VBAN_VMRT_Packet_Data:
def busgain(self) -> tuple: def busgain(self) -> tuple:
"""returns tuple of bus gains""" """returns tuple of bus gains"""
return tuple( 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) for i in range(0, 16, 2)
) )
@@ -201,12 +211,41 @@ class VBAN_VMRT_Packet_Data:
@dataclass @dataclass
class VBAN_VMRT_Packet_Header: class SubscribeHeader:
"""Represents a RESPONSE RT PACKET header""" """Represents the header an RT Packet Service subscription packet"""
name = "Register RTP"
timeout = 15
vban: bytes = "VBAN".encode()
format_sr: bytes = (VBAN_PROTOCOL_SERVICE).to_bytes(1, "little")
format_nbs: bytes = (0).to_bytes(1, "little")
format_nbc: bytes = (VBAN_SERVICE_RTPACKETREGISTER).to_bytes(1, "little")
format_bit: bytes = (timeout & 0x000000FF).to_bytes(1, "little") # timeout
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
framecounter: bytes = (0).to_bytes(4, "little")
@property
def header(self):
header = self.vban
header += self.format_sr
header += self.format_nbs
header += self.format_nbc
header += self.format_bit
header += self.streamname
header += self.framecounter
assert (
len(header) == HEADER_SIZE + 4
), f"expected header size {HEADER_SIZE} bytes + 4 bytes framecounter ({HEADER_SIZE +4} bytes total)"
return header
@dataclass
class VbanRtPacketHeader:
"""Represents the header of a VBAN RT response packet"""
name = "Voicemeeter-RTP" name = "Voicemeeter-RTP"
vban: bytes = "VBAN".encode() vban: bytes = "VBAN".encode()
format_sr: bytes = (0x60).to_bytes(1, "little") format_sr: bytes = (VBAN_PROTOCOL_SERVICE).to_bytes(1, "little")
format_nbs: bytes = (0).to_bytes(1, "little") format_nbs: bytes = (0).to_bytes(1, "little")
format_nbc: bytes = (VBAN_SERVICE_RTPACKET).to_bytes(1, "little") format_nbc: bytes = (VBAN_SERVICE_RTPACKET).to_bytes(1, "little")
format_bit: bytes = (0).to_bytes(1, "little") format_bit: bytes = (0).to_bytes(1, "little")
@@ -220,13 +259,13 @@ class VBAN_VMRT_Packet_Header:
header += self.format_nbc header += self.format_nbc
header += self.format_bit header += self.format_bit
header += self.streamname header += self.streamname
assert len(header) == HEADER_SIZE - 4, f"Header expected {HEADER_SIZE-4} bytes" assert len(header) == HEADER_SIZE, f"expected header size {HEADER_SIZE} bytes"
return header return header
@dataclass @dataclass
class TextRequestHeader: class RequestHeader:
"""Represents a REQUEST RT PACKET header""" """Represents the header of a REQUEST RT PACKET"""
name: str name: str
bps_index: int bps_index: int
@@ -238,7 +277,7 @@ class TextRequestHeader:
@property @property
def sr(self): def sr(self):
return (0x40 + self.bps_index).to_bytes(1, "little") return (VBAN_PROTOCOL_TXT + self.bps_index).to_bytes(1, "little")
@property @property
def nbc(self): def nbc(self):
@@ -257,32 +296,7 @@ class TextRequestHeader:
header += self.bit header += self.bit
header += self.streamname header += self.streamname
header += self.framecounter header += self.framecounter
assert len(header) == HEADER_SIZE, f"Header expected {HEADER_SIZE} bytes" assert (
return header len(header) == HEADER_SIZE + 4
), f"expected header size {HEADER_SIZE} bytes + 4 bytes framecounter ({HEADER_SIZE +4} bytes total)"
@dataclass
class RegisterRTHeader:
"""Represents a REGISTER RT PACKET header"""
name = "Register RTP"
timeout = 15
vban: bytes = "VBAN".encode()
format_sr: bytes = (0x60).to_bytes(1, "little")
format_nbs: bytes = (0).to_bytes(1, "little")
format_nbc: bytes = (VBAN_SERVICE_RTPACKETREGISTER).to_bytes(1, "little")
format_bit: bytes = (timeout & 0x000000FF).to_bytes(1, "little") # timeout
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
framecounter: bytes = (0).to_bytes(4, "little")
@property
def header(self):
header = self.vban
header += self.format_sr
header += self.format_nbs
header += self.format_nbc
header += self.format_bit
header += self.streamname
header += self.framecounter
assert len(header) == HEADER_SIZE, f"Header expected {HEADER_SIZE} bytes"
return header return header

View File

@@ -1,3 +1,4 @@
import time
from abc import abstractmethod from abc import abstractmethod
from typing import Union from typing import Union
@@ -19,7 +20,7 @@ class Strip(IRemote):
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f"strip[{self.index}]"
@property @property
def limit(self) -> int: def limit(self) -> int:
@@ -40,27 +41,32 @@ class Strip(IRemote):
def gain(self, val: float): def gain(self, val: float):
self.setter("gain", val) 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): class PhysicalStrip(Strip):
@classmethod
def make(cls, remote, index):
return type(
f"PhysicalStrip{remote.kind}",
(cls,),
{
"comp": StripComp(remote, index),
"gate": StripGate(remote, index),
"denoiser": StripDenoiser(remote, index),
"eq": StripEQ(remote, index),
},
)
def __str__(self): def __str__(self):
return f"{type(self).__name__}{self.index}" return f"{type(self).__name__}{self.index}"
@property
def comp(self) -> float:
return
@comp.setter
def comp(self, val: float):
self.setter("Comp", val)
@property
def gate(self) -> float:
return
@gate.setter
def gate(self, val: float):
self.setter("gate", val)
@property @property
def device(self): def device(self):
return return
@@ -70,6 +76,182 @@ class PhysicalStrip(Strip):
return return
class StripComp(IRemote):
@property
def identifier(self) -> str:
return f"strip[{self.index}].comp"
@property
def knob(self) -> float:
return
@knob.setter
def knob(self, val: float):
self.setter("", val)
@property
def gainin(self) -> float:
return
@gainin.setter
def gainin(self, val: float):
self.setter("GainIn", val)
@property
def ratio(self) -> float:
return
@ratio.setter
def ratio(self, val: float):
self.setter("Ratio", val)
@property
def threshold(self) -> float:
return
@threshold.setter
def threshold(self, val: float):
self.setter("Threshold", val)
@property
def attack(self) -> float:
return
@attack.setter
def attack(self, val: float):
self.setter("Attack", val)
@property
def release(self) -> float:
return
@release.setter
def release(self, val: float):
self.setter("Release", val)
@property
def knee(self) -> float:
return
@knee.setter
def knee(self, val: float):
self.setter("Knee", val)
@property
def gainout(self) -> float:
return
@gainout.setter
def gainout(self, val: float):
self.setter("GainOut", val)
@property
def makeup(self) -> bool:
return
@makeup.setter
def makeup(self, val: bool):
self.setter("makeup", 1 if val else 0)
class StripGate(IRemote):
@property
def identifier(self) -> str:
return f"strip[{self.index}].gate"
@property
def knob(self) -> float:
return
@knob.setter
def knob(self, val: float):
self.setter("", val)
@property
def threshold(self) -> float:
return
@threshold.setter
def threshold(self, val: float):
self.setter("Threshold", val)
@property
def damping(self) -> float:
return
@damping.setter
def damping(self, val: float):
self.setter("Damping", val)
@property
def bpsidechain(self) -> int:
return
@bpsidechain.setter
def bpsidechain(self, val: int):
self.setter("BPSidechain", val)
@property
def attack(self) -> float:
return
@attack.setter
def attack(self, val: float):
self.setter("Attack", val)
@property
def hold(self) -> float:
return
@hold.setter
def hold(self, val: float):
self.setter("Hold", val)
@property
def release(self) -> float:
return
@release.setter
def release(self, val: float):
self.setter("Release", val)
class StripDenoiser(IRemote):
@property
def identifier(self) -> str:
return f"strip[{self.index}].denoiser"
@property
def knob(self) -> float:
return
@knob.setter
def knob(self, val: float):
self.setter("", val)
class StripEQ(IRemote):
@property
def identifier(self) -> str:
return f"strip[{self.index}].eq"
@property
def on(self):
return
@on.setter
def on(self, val: bool):
self.setter("on", 1 if val else 0)
@property
def ab(self):
return
@ab.setter
def ab(self, val: bool):
self.setter("ab", 1 if val else 0)
class VirtualStrip(Strip): class VirtualStrip(Strip):
def __str__(self): def __str__(self):
return f"{type(self).__name__}{self.index}" return f"{type(self).__name__}{self.index}"
@@ -86,6 +268,12 @@ class VirtualStrip(Strip):
def k(self, val: int): def k(self, val: int):
self.setter("karaoke", val) 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): class StripLevel(IRemote):
def __init__(self, remote, index): def __init__(self, remote, index):
@@ -103,14 +291,28 @@ class StripLevel(IRemote):
self.range = self.level_map[self.index] self.range = self.level_map[self.index]
def getter(self): 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 not self._remote.stopped() and self._remote.event.ldirty:
return tuple( return tuple(
round(-i * 0.01, 1) fget(i)
for i in self._remote.cache["strip_level"][self.range[0] : self.range[-1]] for i in self._remote.cache["strip_level"][
self.range[0] : self.range[-1]
]
)
return tuple(
fget(i)
for i in self._remote._get_levels(self.public_packet)[0][
self.range[0] : self.range[-1]
]
) )
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f"strip[{self.index}]"
@property @property
def prefader(self) -> tuple: def prefader(self) -> tuple:
@@ -143,23 +345,18 @@ class GainLayer(IRemote):
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f"strip[{self.index}]"
@property @property
def gain(self) -> float: def gain(self) -> float:
def fget(): def fget():
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index] val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index]
if val < 10000: if 0 <= val <= 1200:
return -val return val * 0.01
elif val == ((1 << 16) - 1): return (((1 << 16) - 1) - val) * -0.01
return 0
else:
return ((1 << 16) - 1) - val
val = self.getter(f"GainLayer[{self._i}]") val = self.getter(f"GainLayer[{self._i}]")
if val is None: return round(val if val else fget(), 1)
val = fget() * 0.01
return round(val, 1)
@gain.setter @gain.setter
def gain(self, val: float): def gain(self, val: float):
@@ -208,7 +405,7 @@ def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip
Returns a physical or virtual strip subclass Returns a physical or virtual strip subclass
""" """
STRIP_cls = PhysicalStrip if is_phys_strip else VirtualStrip STRIP_cls = PhysicalStrip.make(remote, i) if is_phys_strip else VirtualStrip
CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name] CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i) GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)

View File

@@ -1,10 +1,14 @@
class Subject: import logging
"""Adds support for observers"""
logger = logging.getLogger(__name__)
class Subject:
def __init__(self): def __init__(self):
"""list of current observers""" """Adds support for observers and callbacks"""
self._observers = list() self._observers = list()
self.logger = logger.getChild(self.__class__.__name__)
@property @property
def observers(self) -> list: def observers(self) -> list:
@@ -12,28 +16,57 @@ class Subject:
return self._observers return self._observers
def notify(self, modifier=None): def notify(self, event):
"""run callbacks on update""" """run callbacks on update"""
[o.on_update(modifier) for o in self._observers] for o in self._observers:
if hasattr(o, "on_update"):
o.on_update(event)
else:
if o.__name__ == f"on_{event}":
o()
def add(self, observer): def add(self, observer):
"""adds an observer to _observers""" """adds an observer to observers"""
if observer not in self._observers:
self._observers.append(observer)
else:
print(f"Failed to add: {observer}")
def remove(self, observer):
"""removes an observer from _observers"""
try: try:
self._observers.remove(observer) iterator = iter(observer)
for o in iterator:
if o not in self._observers:
self._observers.append(o)
self.logger.info(f"{o} added to event observers")
else:
self.logger.error(f"Failed to add {o} to event observers")
except TypeError:
if observer not in self._observers:
self._observers.append(observer)
self.logger.info(f"{observer} added to event observers")
else:
self.logger.error(f"Failed to add {observer} to event observers")
register = add
def remove(self, observer):
"""removes an observer from observers"""
try:
iterator = iter(observer)
for o in iterator:
try:
self._observers.remove(o)
self.logger.info(f"{o} removed from event observers")
except ValueError: except ValueError:
print(f"Failed to remove: {observer}") self.logger.error(f"Failed to remove {o} from event observers")
except TypeError:
try:
self._observers.remove(observer)
self.logger.info(f"{observer} removed from event observers")
except ValueError:
self.logger.error(f"Failed to remove {observer} from event observers")
deregister = remove
def clear(self): def clear(self):
"""clears the _observers list""" """clears the observers list"""
self._observers.clear() self._observers.clear()

View File

@@ -7,9 +7,10 @@ def cache_bool(func, param):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
self, *rem = args self, *rem = args
cmd = f"{self.identifier}.{param}" if self._cmd(param) in self._remote.cache:
if cmd in self._remote.cache: return self._remote.cache.pop(self._cmd(param)) == 1
return self._remote.cache.pop(cmd) == 1 if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
@@ -20,9 +21,10 @@ def cache_string(func, param):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
self, *rem = args self, *rem = args
cmd = f"{self.identifier}.{param}" if self._cmd(param) in self._remote.cache:
if cmd in self._remote.cache: return self._remote.cache.pop(self._cmd(param))
return self._remote.cache.pop(cmd) if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
@@ -61,10 +63,26 @@ def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
Evaluates equality of each member in both tuples. Evaluates equality of each member in both tuples.
""" """
for a, b in zip(t0, t1): for a, b in zip(t0, t1):
if b <= 9500: if ((1 << 16) - 1) - b <= 7200:
yield a == b yield a == b
else:
yield True yield True
def deep_merge(dict1, dict2):
"""Generator function for deep merging two dicts"""
for k in set(dict1) | set(dict2):
if k in dict1 and k in dict2:
if isinstance(dict1[k], dict) and isinstance(dict2[k], dict):
yield k, dict(deep_merge(dict1[k], dict2[k]))
else:
yield k, dict2[k]
elif k in dict1:
yield k, dict1[k]
else:
yield k, dict2[k]
Socket = IntEnum("Socket", "register request response", start=0) Socket = IntEnum("Socket", "register request response", start=0)

234
vban_cmd/vban.py Normal file
View File

@@ -0,0 +1,234 @@
from abc import abstractmethod
from .iremote import IRemote
from .kinds import kinds_all
class VbanStream(IRemote):
"""
Implements the common interface
Defines concrete implementation for vban stream
"""
@abstractmethod
def __str__(self):
pass
@property
def identifier(self) -> str:
return f"vban.{self.direction}stream[{self.index}]"
@property
def on(self) -> bool:
return
@on.setter
def on(self, val: bool):
self.setter("on", 1 if val else 0)
@property
def name(self) -> str:
return
@name.setter
def name(self, val: str):
self.setter("name", val)
@property
def ip(self) -> str:
return
@ip.setter
def ip(self, val: str):
self.setter("ip", val)
@property
def port(self) -> int:
return
@port.setter
def port(self, val: int):
if not 1024 <= val <= 65535:
self.logger.warning(
f"port got: {val} but expected a value from 1024 to 65535"
)
self.setter("port", val)
@property
def sr(self) -> int:
return
@sr.setter
def sr(self, val: int):
opts = (11025, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200, 96000)
if val not in opts:
self.logger.warning(f"sr got: {val} but expected a value in {opts}")
self.setter("sr", 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 bit(self) -> int:
return
@bit.setter
def bit(self, val: int):
if val not in (16, 24):
self.logger.warning(f"bit got: {val} but expected value 16 or 24")
self.setter("bit", 1 if (val == 16) else 2)
@property
def quality(self) -> int:
return
@quality.setter
def quality(self, val: int):
if not 0 <= val <= 4:
self.logger.warning(f"quality got: {val} but expected a value from 0 to 4")
self.setter("quality", val)
@property
def route(self) -> int:
return
@route.setter
def route(self, val: int):
if not 0 <= val <= 8:
self.logger.warning(f"route got: {val} but expected a value from 0 to 8")
self.setter("route", val)
class VbanInstream(VbanStream):
"""
class representing a vban instream
subclasses VbanStream
"""
def __str__(self):
return f"{type(self).__name__}{self._remote.kind}{self.index}"
@property
def direction(self) -> str:
return "in"
@property
def sr(self) -> int:
return
@property
def channel(self) -> int:
return
@property
def bit(self) -> int:
return
class VbanAudioInstream(VbanInstream):
"""Represents a VBAN Audio Instream"""
class VbanMidiInstream(VbanInstream):
"""Represents a VBAN Midi Instream"""
class VbanTextInstream(VbanInstream):
"""Represents a VBAN Text Instream"""
class VbanOutstream(VbanStream):
"""
class representing a vban outstream
Subclasses VbanStream
"""
def __str__(self):
return f"{type(self).__name__}{self._remote.kind}{self.index}"
@property
def direction(self) -> str:
return "out"
class VbanAudioOutstream(VbanOutstream):
"""Represents a VBAN Audio Outstream"""
class VbanMidiOutstream(VbanOutstream):
"""Represents a VBAN Midi Outstream"""
def _make_stream_pair(remote, kind):
num_instream, num_outstream, num_midi, num_text = kind.vban
def _make_cls(i, direction):
match direction:
case "in":
if i < num_instream:
return VbanAudioInstream(remote, i)
elif i < num_instream + num_midi:
return VbanMidiInstream(remote, i)
else:
return VbanTextInstream(remote, i)
case "out":
if i < num_outstream:
return VbanAudioOutstream(remote, i)
else:
return VbanMidiOutstream(remote, i)
return (
tuple(_make_cls(i, "in") for i in range(num_instream + num_midi + num_text)),
tuple(_make_cls(i, "out") for i in range(num_outstream + num_midi)),
)
def _make_stream_pairs(remote):
return {kind.name: _make_stream_pair(remote, kind) for kind in kinds_all}
class Vban:
"""
class representing the vban module
Contains two tuples, one for each stream type
"""
def __init__(self, remote):
self.remote = remote
self.instream, self.outstream = _make_stream_pairs(remote)[remote.kind.name]
def enable(self):
"""if VBAN disabled there can be no communication with it"""
def disable(self):
self.remote._set_rt("vban.Enable", 0)
def vban_factory(remote) -> Vban:
"""
Factory method for vban
Returns a class that represents the VBAN module.
"""
VBAN_cls = Vban
return type(f"{VBAN_cls.__name__}", (VBAN_cls,), {})(remote)
def request_vban_obj(remote) -> Vban:
"""
Vban entry point.
Returns a reference to a Vban class of a kind
"""
return vban_factory(remote)

240
vban_cmd/vbancmd.py Normal file
View File

@@ -0,0 +1,240 @@
import logging
import socket
import threading
import time
from abc import ABCMeta, abstractmethod
from pathlib import Path
from queue import Queue
from typing import Iterable, Union
from .error import VBANCMDError
from .event import Event
from .packet import RequestHeader
from .subject import Subject
from .util import Socket, deep_merge, script
from .worker import Producer, Subscriber, Updater
logger = logging.getLogger(__name__)
class VbanCmd(metaclass=ABCMeta):
"""Base class responsible for communicating with the VBAN RT Packet Service"""
DELAY = 0.001
# fmt: off
BPS_OPTS = [
0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
1000000, 1500000, 2000000, 3000000,
]
# fmt: on
def __init__(self, **kwargs):
self.logger = logger.getChild(self.__class__.__name__)
self.event = Event({k: kwargs.pop(k) for k in ("pdirty", "ldirty")})
if not kwargs["ip"]:
kwargs |= self._conn_from_toml()
for attr, val in kwargs.items():
setattr(self, attr, val)
self.packet_request = RequestHeader(
name=self.streamname,
bps_index=self.BPS_OPTS.index(self.bps),
channel=self.channel,
)
self.socks = tuple(
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in Socket
)
self.subject = self.observer = Subject()
self.cache = {}
self._pdirty = False
self._ldirty = False
self._script = str()
self.stop_event = None
@abstractmethod
def __str__(self):
"""Ensure subclasses override str magic method"""
pass
def _conn_from_toml(self) -> dict:
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
def get_filepath():
filepaths = [
Path.cwd() / "vban.toml",
Path.cwd() / "configs" / "vban.toml",
Path.home() / ".config" / "vban-cmd" / "vban.toml",
Path.home() / "Documents" / "Voicemeeter" / "configs" / "vban.toml",
]
for filepath in filepaths:
if filepath.exists():
return filepath
if filepath := get_filepath():
with open(filepath, "rb") as f:
conn = tomllib.load(f)
assert (
"connection" in conn and "ip" in conn["connection"]
), "expected [connection][ip] in vban config"
return conn["connection"]
raise VBANCMDError("no ip provided and no vban.toml located.")
def __enter__(self):
self.login()
return self
def login(self) -> None:
"""Starts the subscriber and updater threads (unless in outbound mode)"""
if not self.outbound:
self.event.info()
self.stop_event = threading.Event()
self.stop_event.clear()
self.subscriber = Subscriber(self, self.stop_event)
self.subscriber.start()
queue = Queue()
self.updater = Updater(self, queue)
self.updater.start()
self.producer = Producer(self, queue, self.stop_event)
self.producer.start()
self.logger.info(
"Successfully logged into VBANCMD {kind} with ip='{ip}', port={port}, streamname='{streamname}'".format(
**self.__dict__
)
)
def stopped(self):
return self.stop_event is None or self.stop_event.is_set()
def _set_rt(self, cmd: str, val: Union[str, float]):
"""Sends a string request command over a network."""
self.socks[Socket.request].sendto(
self.packet_request.header + f"{cmd}={val};".encode(),
(socket.gethostbyname(self.ip), self.port),
)
self.packet_request.framecounter = (
int.from_bytes(self.packet_request.framecounter, "little") + 1
).to_bytes(4, "little")
self.cache[cmd] = val
@script
def sendtext(self, script):
"""Sends a multiple parameter string over a network."""
self.socks[Socket.request].sendto(
self.packet_request.header + script.encode(),
(socket.gethostbyname(self.ip), self.port),
)
self.packet_request.framecounter = (
int.from_bytes(self.packet_request.framecounter, "little") + 1
).to_bytes(4, "little")
self.logger.debug(f"sendtext: {script}")
time.sleep(self.DELAY)
@property
def type(self) -> str:
"""Returns the type of Voicemeeter installation."""
return self.public_packet.voicemeetertype
@property
def version(self) -> str:
"""Returns Voicemeeter's version as a string"""
return "{0}.{1}.{2}.{3}".format(*self.public_packet.voicemeeterversion)
@property
def pdirty(self):
"""True iff a parameter has changed"""
return self._pdirty
@property
def ldirty(self):
"""True iff a level value has changed."""
return self._ldirty
@property
def public_packet(self):
return self._public_packet
def clear_dirty(self) -> None:
while self.pdirty:
time.sleep(self.DELAY)
def _get_levels(self, packet) -> Iterable:
"""
returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
strip levels in PREFADER mode.
"""
return (
packet.inputlevels,
packet.outputlevels,
)
def apply(self, data: dict):
"""
Sets all parameters of a dict
minor delay between each recursion
"""
def target(key):
match key.split("-"):
case ["strip" | "bus" as kls, index] if index.isnumeric():
target = getattr(self, kls)
case [
"vban",
"in" | "instream" | "out" | "outstream" as direction,
index,
] if index.isnumeric():
target = getattr(
self.vban, f"{direction.removesuffix('stream')}stream"
)
case _:
ERR_MSG = f"invalid config key '{key}'"
self.logger.error(ERR_MSG)
raise ValueError(ERR_MSG)
return target[int(index)]
[target(key).apply(di).then_wait() for key, di in data.items()]
def apply_config(self, name):
"""applies a config from memory"""
ERR_MSG = (
f"No config with name '{name}' is loaded into memory",
f"Known configs: {list(self.configs.keys())}",
)
try:
config = self.configs[name]
except KeyError as e:
self.logger.error(("\n").join(ERR_MSG))
raise VBANCMDError(("\n").join(ERR_MSG)) from e
if "extends" in config:
extended = config["extends"]
config = {
k: v
for k, v in deep_merge(self.configs[extended], config)
if k not in ("extends")
}
self.logger.debug(
f"profile '{name}' extends '{extended}', profiles merged.."
)
self.apply(config)
self.logger.info(f"Profile '{name}' applied!")
def logout(self) -> None:
if not self.stopped():
self.logger.debug("events thread shutdown started")
self.stop_event.set()
for t in (self.producer, self.subscriber):
t.join()
[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) -> None:
self.logout()

View File

@@ -1,66 +1,95 @@
import logging
import socket import socket
import threading import threading
import time import time
from enum import IntEnum
from typing import Optional from typing import Optional
from .packet import ( from .error import VBANCMDConnectionError
HEADER_SIZE, from .packet import HEADER_SIZE, SubscribeHeader, VbanRtPacket, VbanRtPacketHeader
RegisterRTHeader,
VBAN_VMRT_Packet_Data,
VBAN_VMRT_Packet_Header,
)
from .util import Socket from .util import Socket
logger = logging.getLogger(__name__)
class Subscriber(threading.Thread): class Subscriber(threading.Thread):
"""fire a subscription packet every 10 seconds""" """fire a subscription packet every 10 seconds"""
def __init__(self, remote): def __init__(self, remote, stop_event):
super().__init__(name="subscriber", target=self.register, daemon=True) super().__init__(name="subscriber", daemon=False)
self._rem = remote self._remote = remote
self.register_header = RegisterRTHeader() self.stop_event = stop_event
self.logger = logger.getChild(self.__class__.__name__)
self.packet = SubscribeHeader()
def register(self): def run(self):
while self._rem.running: while not self.stopped():
try: try:
self._rem.socks[Socket.register].sendto( self._remote.socks[Socket.register].sendto(
self.register_header.header, self.packet.header,
(socket.gethostbyname(self._rem.ip), self._rem.port), (socket.gethostbyname(self._remote.ip), self._remote.port),
) )
count = int.from_bytes(self.register_header.framecounter, "little") + 1 self.packet.framecounter = (
self.register_header.framecounter = count.to_bytes(4, "little") int.from_bytes(self.packet.framecounter, "little") + 1
time.sleep(10) ).to_bytes(4, "little")
self.wait_until_stopped(10)
except socket.gaierror as e: except socket.gaierror as e:
print(f"Unable to resolve hostname {self._rem.ip}") self.logger.exception(f"{type(e).__name__}: {e}")
self._rem.socks[Socket.register].close() raise VBANCMDConnectionError(
raise e f"unable to resolve hostname {self._remote.ip}"
) from e
self.logger.debug(f"terminating {self.name} thread")
def stopped(self):
return self.stop_event.is_set()
def wait_until_stopped(self, timeout, period=0.2):
must_end = time.time() + timeout
while time.time() < must_end:
if self.stopped():
break
time.sleep(period)
class Updater(threading.Thread): class Producer(threading.Thread):
""" """Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
continously updates the public packet
notifies observers of event updates def __init__(self, remote, queue, stop_event):
""" super().__init__(name="producer", daemon=False)
self._remote = remote
def __init__(self, remote): self.queue = queue
super().__init__(name="updater", target=self.update, daemon=True) self.stop_event = stop_event
self._rem = remote self.logger = logger.getChild(self.__class__.__name__)
self._rem.socks[Socket.response].bind( self.packet_expected = VbanRtPacketHeader()
(socket.gethostbyname(socket.gethostname()), self._rem.port) self._remote.socks[Socket.response].settimeout(self._remote.timeout)
self._remote.socks[Socket.response].bind(
(socket.gethostbyname(socket.gethostname()), self._remote.port)
) )
self.expected_packet = VBAN_VMRT_Packet_Header() self._remote._public_packet = self._get_rt()
self._rem._public_packet = self._get_rt() (
self._remote.cache["strip_level"],
self._remote.cache["bus_level"],
) = self._remote._get_levels(self._remote.public_packet)
def _fetch_rt_packet(self) -> Optional[VBAN_VMRT_Packet_Data]: def _get_rt(self) -> VbanRtPacket:
"""Returns a valid RT Data Packet or None""" """Attempt to fetch data packet until a valid one found"""
data, _ = self._rem.socks[Socket.response].recvfrom(2048)
# check for packet data def fget():
data = None
while not data:
data = self._fetch_rt_packet()
return data
return fget()
def _fetch_rt_packet(self) -> Optional[VbanRtPacket]:
try:
data, _ = self._remote.socks[Socket.response].recvfrom(2048)
# do we have packet data?
if len(data) > HEADER_SIZE: if len(data) > HEADER_SIZE:
# check if packet is of type VBAN # is the packet of type VBAN RT response?
if self.expected_packet.header == data[: HEADER_SIZE - 4]: if self.packet_expected.header == data[:HEADER_SIZE]:
return VBAN_VMRT_Packet_Data( return VbanRtPacket(
_kind=self._remote.kind,
_voicemeeterType=data[28:29], _voicemeeterType=data[28:29],
_reserved=data[29:30], _reserved=data[29:30],
_buffersize=data[30:32], _buffersize=data[30:32],
@@ -84,40 +113,72 @@ class Updater(threading.Thread):
_stripLabelUTF8c60=data[452:932], _stripLabelUTF8c60=data[452:932],
_busLabelUTF8c60=data[932:1412], _busLabelUTF8c60=data[932:1412],
) )
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 _get_rt(self) -> VBAN_VMRT_Packet_Data: def stopped(self):
"""Attempt to fetch data packet until a valid one found""" return self.stop_event.is_set()
def fget(): def run(self):
data = False while not self.stopped():
while not data:
data = self._fetch_rt_packet()
time.sleep(self._rem.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:
start = time.time()
_pp = self._get_rt() _pp = self._get_rt()
self._rem._strip_buf, self._rem._bus_buf = self._rem._get_levels(_pp) pdirty = _pp.pdirty(self._remote.public_packet)
self._rem._pdirty = _pp.pdirty(self._rem.public_packet) ldirty = _pp.ldirty(
self._remote.cache["strip_level"], self._remote.cache["bus_level"]
)
if self._rem.event.ldirty and self._rem.ldirty: if pdirty or ldirty:
self._rem.cache["strip_level"] = self._rem._strip_buf self._remote._public_packet = _pp
self._rem.cache["bus_level"] = self._rem._bus_buf self._remote._pdirty = pdirty
self._rem.subject.notify("ldirty") self._remote._ldirty = ldirty
if self._rem.public_packet != _pp:
self._rem._public_packet = _pp if self._remote.event.pdirty:
if self._rem.event.pdirty and self._rem.pdirty: self.queue.put("pdirty")
self._rem.subject.notify("pdirty") if self._remote.event.ldirty:
elapsed = time.time() - start self.queue.put("ldirty")
if self._rem.ratelimit - elapsed > 0: time.sleep(self._remote.ratelimit)
time.sleep(self._rem.ratelimit - elapsed) self.logger.debug(f"terminating {self.name} thread")
self.queue.put(None)
class Updater(threading.Thread):
"""
continously updates the public packet
notifies observers of event updates
"""
def __init__(self, remote, queue):
super().__init__(name="updater", daemon=True)
self._remote = remote
self.queue = queue
self.logger = logger.getChild(self.__class__.__name__)
self._remote._strip_comp = [False] * (self._remote.kind.num_strip_levels)
self._remote._bus_comp = [False] * (self._remote.kind.num_bus_levels)
def run(self):
"""
Continously update observers of dirty states.
Generate _strip_comp, _bus_comp and update level cache if ldirty.
"""
while event := self.queue.get():
if event == "pdirty" and self._remote.pdirty:
self._remote.subject.notify(event)
elif event == "ldirty" and self._remote.ldirty:
self._remote._strip_comp, self._remote._bus_comp = (
self._remote._public_packet._strip_comp,
self._remote._public_packet._bus_comp,
)
(
self._remote.cache["strip_level"],
self._remote.cache["bus_level"],
) = (
self._remote._public_packet.inputlevels,
self._remote._public_packet.outputlevels,
)
self._remote.subject.notify(event)
self.logger.debug(f"terminating {self.name} thread")