mirror of
https://github.com/onyx-and-iris/vban-cmd-python.git
synced 2026-04-07 16:13:30 +00:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98ec9b715f | |||
| 5f7b62a0e0 | |||
| d1bcbfed6f | |||
| ab80bbf226 | |||
| ad58852a77 | |||
| 5363584940 | |||
| 9f43ee18d3 | |||
| 3cde874a3c | |||
| 3d01321be3 | |||
| 2dd52a7258 | |||
| 28cbef5ef6 | |||
| 5b3b35fca3 | |||
| 7b3149a1e1 | |||
| 230d9f0eb3 | |||
| c9a505df0a | |||
| 3e3bec6d50 | |||
| 55b3125e10 | |||
| 7b3340042c | |||
| 6ea0859180 | |||
| 81ed963bea | |||
| 0b99b6a67f | |||
| 86d0aa91c3 | |||
| cf66ae252c | |||
| 42f6f29d1e | |||
| a210766b7b | |||
| 7d741d6e8b | |||
| 8be9d3cb7f | |||
| 23b99cb66b | |||
| 2fd7b8ad8b | |||
| c851cb5abe | |||
| dc681f50d0 | |||
| a0ec00652b | |||
| 69263c22f2 | |||
| ad2cfeaae6 | |||
| 1123fe6432 | |||
| 3c3e415d7e | |||
| 8cfeb45fcb | |||
| 10b38b3fcc | |||
| ff5ac193c8 | |||
| 2f3cd0e07f | |||
| d689b3a301 | |||
| a8ef82166c | |||
| 79f06ecc79 | |||
| b291c3a477 | |||
| c335d35b9f | |||
| 911d2f64a6 | |||
| e58d6c7242 | |||
| 870a95b41e | |||
| 59880bf582 | |||
| cc58d1f081 | |||
| e37dea38b3 | |||
| 7f3b0ac7c9 | |||
| 0512fac710 | |||
| d439da725c | |||
| 45ffed9f63 | |||
| 14f79d1388 | |||
| b45bd38706 | |||
| 312b5c5842 | |||
| ed8e281f7f | |||
| efdcfce387 | |||
| ad88286509 | |||
| ecbdd2778f | |||
| 1babf85a89 | |||
| fbd1d54f9b | |||
| 96e9d6f4fd | |||
| 51394c0076 | |||
| 91feccc509 | |||
| c9c365ac54 | |||
| 1742ff839e | |||
| 5299d9ec6b | |||
| bc2cd3e7a5 | |||
| af68c423a6 | |||
| 16df0d559e | |||
| dad5ee9e9d | |||
|
|
694e1036de | ||
| 8436634371 | |||
| 074ba4fe77 | |||
|
|
2b4e64ed76 | ||
| 21df4998a2 | |||
| 7bff293820 | |||
| c8d0a0078d | |||
| 87a1d62414 | |||
| f863723a4e | |||
| afa1867abc | |||
| fcb656b7d0 | |||
| 9c0e2bef39 | |||
| 36692d1bc7 | |||
| 753714b639 | |||
| 27a26b8fe9 | |||
| 79260a0e47 | |||
| f9bcbfa74a | |||
| 0f2fb7121d | |||
| a635109308 | |||
| a61e09b075 | |||
| 763e44df12 | |||
| 69472a783e | |||
| 9a1ba06a21 | |||
| 14b2ee473a | |||
| ca2427c29a | |||
| ebacdcf82a | |||
| 7416108489 | |||
| bd6e57b3c6 | |||
| eed036ca03 | |||
| 55211b9b19 | |||
| 4af7c0f694 | |||
| f082fa8ac5 | |||
| cbcca14481 | |||
| f584d53835 | |||
| 72d182a488 | |||
| ee32f92914 | |||
| 3b65035e50 | |||
| c8b4bde49d | |||
| 47e9203b1e | |||
| d48e7ecd79 | |||
| 7e09a0d321 | |||
| d41ee1a12a | |||
| 1e499cd99d | |||
| 9bf52b5c11 | |||
| 77ba347e99 | |||
| 94fa33cebf | |||
| ef105d878b | |||
| 956f759e73 | |||
| dab519be9f | |||
| a4b91bf5c6 | |||
| 2a98707bf8 | |||
| 8e30c57020 | |||
| 04e18b304b | |||
| 4de384c66c | |||
| 2c8659a4e5 | |||
| 41e427e46b | |||
| fc6fdb44b5 | |||
| b49dc3b9b3 | |||
| 1ad0347478 | |||
| 2c8e4cc87c | |||
| fc3b31dfa7 | |||
| 544e0f2a32 | |||
| f6d92d1c34 | |||
| 10dbf63056 | |||
| 6ddd4151b4 | |||
| 8b912a2d08 | |||
| d2a5fe197e | |||
| 0970bfe0b5 | |||
| 54041503c9 | |||
| 9d015755eb | |||
| ca9a31c94a | |||
| 7a3abfc372 | |||
| 37a9c88867 | |||
| df7996a846 | |||
| 3f5dc7c376 | |||
| 05cbc432b2 | |||
| 174d95d08d | |||
| fc324fecc4 | |||
| 449cb9b3c1 | |||
| cdccc603d1 | |||
| a8bb9711af | |||
| 5bb0c2731e | |||
| 372dba0b6b | |||
| 226fc5ead7 | |||
| 9196a4e267 | |||
| 8485992495 | |||
| 91e49cbb55 | |||
| 3c85903554 | |||
| a730edc2c2 | |||
| 90acafe95b | |||
| 5f4fdcb0eb | |||
| d5219d66f7 | |||
| c74d827154 | |||
|
|
f6218d2032 | ||
|
|
4aacc60857 | ||
|
|
8f9ac47d02 | ||
|
|
90e994c193 | ||
|
|
44cd13aa48 | ||
|
|
87eb61170e | ||
|
|
01c99d5b31 | ||
|
|
3144a95e07 | ||
|
|
1833b28c8d | ||
|
|
ee3a871d23 | ||
|
|
197f81aa73 | ||
|
|
362873c5be | ||
|
|
c86f7971b0 | ||
|
|
bac60e5ed3 | ||
|
|
692acc8dd0 | ||
|
|
d57269f147 | ||
|
|
be69d905c4 | ||
|
|
5ceb8f775a | ||
|
|
e0f4aab257 | ||
|
|
4ee37f54c5 | ||
|
|
550df917fb | ||
|
|
2f82e0b1fc | ||
|
|
0c60fe3d5e | ||
|
|
243a43ac22 | ||
|
|
49354d6d55 | ||
|
|
5c9ac4d78f | ||
|
|
02b21b6989 | ||
|
|
4659cf7cdb | ||
|
|
8663aab2ce | ||
|
|
a029011012 | ||
|
|
bfa1a718f9 | ||
|
|
2048a807d1 | ||
|
|
566bff3ced | ||
|
|
70dbee6f02 | ||
|
|
c14196fc31 | ||
|
|
c28398c5f6 | ||
|
|
5177c2d297 | ||
|
|
23bc15e437 | ||
|
|
db96872965 | ||
|
|
1169435104 | ||
|
|
f46abedf12 | ||
|
|
733fab45b4 | ||
|
|
444f95a9d6 | ||
|
|
14e538dca6 | ||
|
|
af5e81c339 | ||
|
|
aadfbd3925 | ||
|
|
4ef3d1f225 | ||
|
|
aea2be624e |
53
.github/workflows/publish.yml
vendored
Normal file
53
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
pip install poetry==2.3.1
|
||||
poetry --version
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
poetry install --only-root
|
||||
poetry build
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist
|
||||
|
||||
pypi-publish:
|
||||
needs: build
|
||||
name: Upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/vban-cmd/
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./dist
|
||||
19
.github/workflows/ruff.yml
vendored
Normal file
19
.github/workflows/ruff.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Ruff
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: 'format --check --diff'
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,6 +1,3 @@
|
||||
# quick test
|
||||
quick.py
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -88,7 +85,7 @@ ipython_config.py
|
||||
# pyenv
|
||||
# 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:
|
||||
# .python-version
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
@@ -153,3 +150,14 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# test files
|
||||
test-*.py
|
||||
|
||||
#config
|
||||
config.toml
|
||||
vban.toml
|
||||
|
||||
.vscode/
|
||||
|
||||
PING_FEATURE.md
|
||||
187
CHANGELOG.md
187
CHANGELOG.md
@@ -11,6 +11,193 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
|
||||
|
||||
- [x]
|
||||
|
||||
## [2.9.0] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Recorder class, see [Recorder](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#recorder) in README.
|
||||
- Ping/pong implemented. If a pong is not received {VbanCmd}.login() will fail fast. This prevents the rt listener threads from starting up.
|
||||
- It has the added benefit of automatically detecting the type of VBAN server (Voicemeeter or Matrix).
|
||||
- A thread lock around the framecounter to improve thread safety since it can be accessed by both the main thread and the Producer thread.
|
||||
|
||||
|
||||
## [2.7.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- new kind `matrix` has been added, it does two things:
|
||||
- scales the interface according to `potato` kind, in practice this has no affect but it's required by the builder classes.
|
||||
- disables the rt listener threads since we aren't expecting to receive any from a Matrix VBAN server.
|
||||
- however, matrix responses may still be received with the {VbanCmd}.sendtext() method.
|
||||
|
||||
### Changed
|
||||
|
||||
- `outbound` kwarg has been renamed to `disable_rt_listeners`. Since it's job is to disable the listener threads for incoming RT packets this new name is more descriptive.
|
||||
- dataclasses representing packet headers and packets with ident:0 and ident:1 have been moved into an internal packet module.
|
||||
|
||||
### Removed
|
||||
|
||||
- {VbanCmd}.sendtext() @script decorator removed. It's purpose was to attempt to convert a dictionary to a script but it was poorly implemented and there exists the {VbanCmd}.apply() method already.
|
||||
|
||||
## [2.6.0] - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- support for packet with [ident:1](https://github.com/onyx-and-iris/Voicemeeter-SDK/blob/3be2c1c36563afbd6df3da8436406c77d2cc1f10/VoicemeeterRemote.h#L982) in VBAN TEXT subprotocol.
|
||||
- This includes Strip 3D, PEQ, comp, gate, denoiser and pitch parameters.
|
||||
|
||||
## [2.5.2] - 2025-01-25
|
||||
|
||||
### Changed
|
||||
|
||||
- ip kwargs defaults to 'localhost'
|
||||
- bps kwarg defaults to 256000.
|
||||
- factory builder steps now logged at `DEBUG` level.
|
||||
- Internal socket changes, they don't affect interface usage.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
414
README.md
414
README.md
@@ -1,48 +1,61 @@
|
||||
[](https://badge.fury.io/py/vban-cmd)
|
||||
[](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://pycqa.github.io/isort/)
|
||||
[](https://python-poetry.org/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||

|
||||

|
||||

|
||||
|
||||
# VBAN CMD
|
||||
|
||||
This python interface allows you to get and set Voicemeeter parameter values over a network.
|
||||
This python interface allows you to send Voicemeeter/Matrix commands over a network.
|
||||
|
||||
It may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python)
|
||||
It offers the same public API as [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python).
|
||||
|
||||
There is no support for audio transfer in this package, only parameters.
|
||||
Only the VBAN SERVICE/TEXT subprotocols are supported, there is no support for AUDIO or MIDI in this package.
|
||||
|
||||
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
## Tested against
|
||||
|
||||
- Basic 1.0.8.4
|
||||
- Banana 2.0.6.4
|
||||
- Potato 3.0.2.4
|
||||
- Basic 1.1.2.2
|
||||
- Banana 2.1.2.2
|
||||
- Potato 3.1.2.2
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Voicemeeter](https://voicemeeter.com/)
|
||||
- Python 3.11 or greater
|
||||
- Python 3.10 or greater
|
||||
|
||||
## Installation
|
||||
|
||||
### `Pip`
|
||||
|
||||
Install vban-cmd package from your console
|
||||
|
||||
`pip install vban-cmd`
|
||||
```console
|
||||
pip install vban-cmd
|
||||
```
|
||||
|
||||
## `Use`
|
||||
|
||||
#### Connection
|
||||
|
||||
Load VBAN connection info from toml config. A valid `vban.toml` might look like this:
|
||||
|
||||
```toml
|
||||
[connection]
|
||||
host = "localhost"
|
||||
port = 6980
|
||||
streamname = "Command1"
|
||||
```
|
||||
|
||||
It should be placed in \<user home directory\> / "Documents" / "Voicemeeter" / "configs"
|
||||
|
||||
Alternatively you may pass `host`, `port`, `streamname` as keyword arguments.
|
||||
|
||||
#### `__main__.py`
|
||||
|
||||
Simplest use case, use a context manager to request a VbanCmd class of a kind.
|
||||
|
||||
Login and logout are handled for you in this scenario.
|
||||
|
||||
#### `__main__.py`
|
||||
|
||||
```python
|
||||
import vban_cmd
|
||||
|
||||
@@ -52,24 +65,28 @@ class ManyThings:
|
||||
self.vban = vban
|
||||
|
||||
def things(self):
|
||||
self.vban.strip[0].label = "podmic"
|
||||
self.vban.strip[0].label = 'podmic'
|
||||
self.vban.strip[0].mute = True
|
||||
print(
|
||||
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}"
|
||||
f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
|
||||
)
|
||||
|
||||
def other_things(self):
|
||||
info = (
|
||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
||||
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
|
||||
)
|
||||
self.vban.bus[3].gain = -6.3
|
||||
self.vban.bus[4].eq = True
|
||||
print("\n".join(info))
|
||||
info = (
|
||||
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||
)
|
||||
print('\n'.join(info))
|
||||
|
||||
|
||||
def main():
|
||||
with vban_cmd.api(kind_id, **opts) as vban:
|
||||
KIND_ID = 'banana'
|
||||
|
||||
with vban_cmd.api(
|
||||
KIND_ID, host='localhost', port=6980, streamname='Command1'
|
||||
) as vban:
|
||||
do = ManyThings(vban)
|
||||
do.things()
|
||||
do.other_things()
|
||||
@@ -77,33 +94,29 @@ def main():
|
||||
# set many parameters at once
|
||||
vban.apply(
|
||||
{
|
||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-2": {"mute": True},
|
||||
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-2': {'mute': True},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kind_id = "banana"
|
||||
opts = {
|
||||
"ip": "<ip address>",
|
||||
"streamname": "Command1",
|
||||
"port": 6980,
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
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`
|
||||
- `banana`
|
||||
- `potato`
|
||||
|
||||
A fourth kind `matrix` has been added, if you pass it as a KIND_ID you are expected to use the [{VbanCmd}.sendtext()](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#vbansendtextscript) method for sending text requests.
|
||||
|
||||
## `Available commands`
|
||||
|
||||
### Strip
|
||||
@@ -116,17 +129,116 @@ The following properties are available.
|
||||
- `label`: string
|
||||
- `gain`: float, -60 to 12
|
||||
- `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
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[3].gain = 3.7
|
||||
print(strip[0].label)
|
||||
print(vban.strip[0].label)
|
||||
```
|
||||
|
||||
The following methods are available.
|
||||
|
||||
- `appgain(name, value)`: string, float, from 0.0 to 1.0
|
||||
|
||||
Set the gain in db by value for the app matching name.
|
||||
|
||||
- `appmute(name, value)`: string, bool
|
||||
|
||||
Set mute state as value for the app matching name.
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[5].appmute('Spotify', True)
|
||||
vban.strip[5].appgain('Spotify', 0.5)
|
||||
```
|
||||
|
||||
##### 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 `knob` is 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 `knob` is 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
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[0].eq.ab = True
|
||||
```
|
||||
|
||||
##### Strip.EQ.Channel.Cell
|
||||
|
||||
The following properties are available.
|
||||
|
||||
- `on`: boolean
|
||||
- `type`: int, from 0 up to 6
|
||||
- `f`: float, from 20.0 up to 20_000.0
|
||||
- `gain`: float, from -36.0 up to 18.0
|
||||
- `q`: float, from 0.3 up to 100
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[0].eq.channel[0].cell[2].on = True
|
||||
vban.strip[1].eq.channel[0].cell[2].f = 5000
|
||||
```
|
||||
|
||||
Strip EQ parameters are defined for PhysicalStrips, potato version only.
|
||||
|
||||
Only channel[0] properties are readable over VBAN.
|
||||
|
||||
##### Gainlayers
|
||||
|
||||
- `gain`: float, from -60.0 to 12.0
|
||||
@@ -158,8 +270,6 @@ Level properties will return -200.0 if no audio detected.
|
||||
The following properties are available.
|
||||
|
||||
- `mono`: boolean
|
||||
- `eq`: boolean
|
||||
- `eq_ab`: boolean
|
||||
- `mute`: boolean
|
||||
- `label`: string
|
||||
- `gain`: float, -60 to 12
|
||||
@@ -167,10 +277,20 @@ The following properties are available.
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.bus[4].eq = true
|
||||
print(vban.bus[0].label)
|
||||
```
|
||||
|
||||
##### Bus.EQ
|
||||
|
||||
The following properties are available.
|
||||
|
||||
- `on`: boolean
|
||||
- `ab`: boolean
|
||||
|
||||
```python
|
||||
vban.bus[4].eq.on = true
|
||||
```
|
||||
|
||||
##### Modes
|
||||
|
||||
The following properties are available.
|
||||
@@ -213,6 +333,56 @@ print(vban.bus[0].levels.all)
|
||||
|
||||
`levels.all` will return -200.0 if no audio detected.
|
||||
|
||||
### Strip | Bus
|
||||
|
||||
The following methods are available.
|
||||
|
||||
- `fadeto(amount, time)`: float, int
|
||||
- `fadeby(amount, time)`: float, int
|
||||
|
||||
Modify gain to or by the selected amount in db over a time interval in ms.
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[0].fadeto(-10.3, 1000)
|
||||
vban.bus[3].fadeby(-5.6, 500)
|
||||
```
|
||||
|
||||
### Recorder
|
||||
|
||||
The following methods are available
|
||||
|
||||
- `play()`
|
||||
- `stop()`
|
||||
- `pause()`
|
||||
- `record()`
|
||||
- `ff()`
|
||||
- `rew()`
|
||||
- `load(filepath)`: raw string
|
||||
- `goto(time_string)`: time string in format `hh:mm:ss`
|
||||
|
||||
The following properties are available
|
||||
|
||||
- `samplerate`: int, (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
|
||||
- `bitresolution`: int, (8, 16, 24, 32)
|
||||
- `channel`: int, from 1 to 8
|
||||
- `kbps`: int, (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
|
||||
- `gain`: float, from -60.0 to 12.0
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.recorder.play()
|
||||
vban.recorder.stop()
|
||||
|
||||
# filepath as raw string
|
||||
vban.recorder.load(r'C:\music\mytune.mp3')
|
||||
|
||||
# set the goto time to 1m 30s
|
||||
vban.recorder.goto('00:01:30')
|
||||
```
|
||||
|
||||
### Command
|
||||
|
||||
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
|
||||
@@ -242,8 +412,10 @@ vban.command.showvbanchat = true
|
||||
```python
|
||||
vban.apply(
|
||||
{
|
||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-2": {"mute": True},
|
||||
'strip-0': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-1': {'mute': True, 'mode': 'composite'},
|
||||
'bus-2': {'eq': {'on': True}},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -251,8 +423,8 @@ vban.apply(
|
||||
Or for each class you may do:
|
||||
|
||||
```python
|
||||
vban.strip[0].apply(mute: true, gain: 3.2, A1: true)
|
||||
vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24)
|
||||
vban.strip[0].apply({'mute': True, 'gain': 3.2, 'A1': True})
|
||||
vban.vban.outstream[0].apply({'on': True, 'name': 'streamname', 'bit': 24})
|
||||
```
|
||||
|
||||
## Config Files
|
||||
@@ -261,7 +433,7 @@ vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24)
|
||||
|
||||
You may load config files in TOML format.
|
||||
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
|
||||
import vban_cmd
|
||||
@@ -271,64 +443,116 @@ with vban_cmd.api('banana') as vban:
|
||||
|
||||
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 pass the following optional keyword arguments:
|
||||
You may also load a config that extends another config with overrides or additional parameters.
|
||||
|
||||
- `ip`: str, ip or hostname of remote machine
|
||||
- `streamname`: str, name of the stream to connect to.
|
||||
- `port`: int=6980, vban udp port of remote machine.
|
||||
- `subs`: dict={"pdirty": True, "ldirty": False}, controls which updates to listen for.
|
||||
- `pdirty`: parameter updates
|
||||
- `ldirty`: level updates
|
||||
You just need to define a key `extends` in the config TOML, that names the config to be extended.
|
||||
|
||||
#### Event updates
|
||||
Three example 'extender' configs are included with the repo. You may load them with:
|
||||
|
||||
To receive event updates you should do the following:
|
||||
```python
|
||||
import vban_cmd
|
||||
with vban_cmd.api('banana') as vm:
|
||||
vm.apply_config('extender')
|
||||
```
|
||||
|
||||
- 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.
|
||||
## Events
|
||||
|
||||
See `examples/observer` for a demonstration.
|
||||
|
||||
Level updates are considered high volume, by default they are NOT listened for.
|
||||
|
||||
Each of the update types may be enabled/disabled separately.
|
||||
Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
import vban_cmd
|
||||
# Listen for level updates
|
||||
|
||||
opts = {
|
||||
"ip": "<ip address>",
|
||||
"streamname": "Command1",
|
||||
"port": 6980,
|
||||
"subs": {"ldirty": True},
|
||||
'host': '<ip address>',
|
||||
'streamname': 'Command1',
|
||||
'port': 6980,
|
||||
}
|
||||
with vban_cmd.api('banana', **opts) as vban:
|
||||
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`
|
||||
|
||||
You may also add/remove event subscriptions as necessary with the Event class.
|
||||
Use the event class to toggle updates as necessary.
|
||||
|
||||
The following properties are available:
|
||||
|
||||
- `pdirty`: boolean
|
||||
- `ldirty`: boolean
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.event.add("ldirty")
|
||||
vban.event.ldirty = True
|
||||
|
||||
vban.event.remove("pdirty")
|
||||
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:
|
||||
|
||||
- `host`: str='localhost', ip or hostname of remote machine
|
||||
- `port`: int=6980, vban udp port of remote machine.
|
||||
- `streamname`: str='Command1', name of the stream to connect to.
|
||||
- `bps`: int=256000, bps rate of the stream.
|
||||
- `channel`: int=0, channel on which to send the UDP requests.
|
||||
- `pdirty`: boolean=False, parameter updates
|
||||
- `ldirty`: boolean=False, level updates
|
||||
- `script_ratelimit`: float=0.05, default to 20 script requests per second. This affects vban.sendtext() specifically.
|
||||
- `timeout`: int=5, timeout for socket operations.
|
||||
- `disable_rt_listeners`: boolean=False, set `True` if you don't wish to receive RT packets.
|
||||
- You can still send Matrix string requests ending with `?` and receive a response.
|
||||
|
||||
#### `vban.pdirty`
|
||||
|
||||
True iff a parameter has been changed.
|
||||
@@ -342,24 +566,46 @@ True iff a level value has been changed.
|
||||
Sends a script block as a string request, for example:
|
||||
|
||||
```python
|
||||
vban.sendtext("Strip[0].Mute=1;Bus[0].Mono=1")
|
||||
vban.sendtext('Strip[0].Mute=1;Bus[0].Mono=1')
|
||||
```
|
||||
|
||||
#### `vban.public_packet`
|
||||
You can even use it to send matrix commands:
|
||||
|
||||
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).
|
||||
```python
|
||||
vban.sendtext('Point(ASIO128.IN[1..4],ASIO128.OUT[1]).dBGain = -3.0')
|
||||
|
||||
### `Errors`
|
||||
vban.sendtext('Command.Version = ?')
|
||||
```
|
||||
|
||||
- `errors.VMCMDErrors`: Base VMCMD error class.
|
||||
## Errors
|
||||
|
||||
### `Tests`
|
||||
- `errors.VBANCMDError`: Base VBANCMD Exception class.
|
||||
- `errors.VBANCMDConnectionError`: Exception raised when connection/timeout errors occur.
|
||||
|
||||
First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/vban-cmd-python#installation)
|
||||
## Logging
|
||||
|
||||
Then from tests directory:
|
||||
It's possible to see the messages sent by the interface's setters and getters, may be useful for debugging.
|
||||
|
||||
`pytest -v`
|
||||
example:
|
||||
```python
|
||||
import vban_cmd
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
opts = {'host': 'localhost', 'port': 6980, 'streamname': 'Command1'}
|
||||
with vban_cmd.api('banana', **opts) as vban:
|
||||
...
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
Install [poetry](https://python-poetry.org/docs/#installation) and then:
|
||||
|
||||
```powershell
|
||||
poetry poe test-basic
|
||||
poetry poe test-banana
|
||||
poetry poe test-potato
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
37
__main__.py
37
__main__.py
@@ -6,24 +6,28 @@ class ManyThings:
|
||||
self.vban = vban
|
||||
|
||||
def things(self):
|
||||
self.vban.strip[0].label = "podmic"
|
||||
self.vban.strip[0].label = 'podmic'
|
||||
self.vban.strip[0].mute = True
|
||||
print(
|
||||
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}"
|
||||
f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
|
||||
)
|
||||
|
||||
def other_things(self):
|
||||
info = (
|
||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
||||
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
|
||||
)
|
||||
self.vban.bus[3].gain = -6.3
|
||||
self.vban.bus[4].eq = True
|
||||
print("\n".join(info))
|
||||
info = (
|
||||
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||
)
|
||||
print('\n'.join(info))
|
||||
|
||||
|
||||
def main():
|
||||
with vban_cmd.api(kind_id, **opts) as vban:
|
||||
KIND_ID = 'banana'
|
||||
|
||||
with vban_cmd.api(
|
||||
KIND_ID, ip='gamepc.local', port=6980, streamname='Command1'
|
||||
) as vban:
|
||||
do = ManyThings(vban)
|
||||
do.things()
|
||||
do.other_things()
|
||||
@@ -31,21 +35,12 @@ def main():
|
||||
# set many parameters at once
|
||||
vban.apply(
|
||||
{
|
||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-2": {"mute": True},
|
||||
"button-0": {"state": True},
|
||||
"vban-in-0": {"on": True},
|
||||
"vban-out-1": {"name": "streamname"},
|
||||
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-2': {'mute': True},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kind_id = "banana"
|
||||
opts = {
|
||||
"ip": "<ip address>",
|
||||
"streamname": "Command1",
|
||||
"port": 6980,
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
label = "PhysStrip0"
|
||||
A1 = true
|
||||
gain = -8.8
|
||||
comp = 3.2
|
||||
comp.knob = 3.2
|
||||
|
||||
[strip-1]
|
||||
label = "PhysStrip1"
|
||||
B1 = true
|
||||
gate = 4.1
|
||||
gate.knob = 4.1
|
||||
|
||||
[strip-2]
|
||||
label = "PhysStrip2"
|
||||
@@ -31,12 +31,12 @@ mono = true
|
||||
|
||||
[bus-2]
|
||||
label = "PhysBus2"
|
||||
eq = true
|
||||
eq.on = true
|
||||
mode = "composite"
|
||||
|
||||
[bus-3]
|
||||
label = "VirtBus0"
|
||||
eq_ab = true
|
||||
eq.ab = true
|
||||
mode = "upmix61"
|
||||
|
||||
[bus-4]
|
||||
|
||||
12
configs/banana/extender.toml
Normal file
12
configs/banana/extender.toml
Normal 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"
|
||||
12
configs/basic/extender.toml
Normal file
12
configs/basic/extender.toml
Normal 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"
|
||||
@@ -2,12 +2,12 @@
|
||||
label = "PhysStrip0"
|
||||
A1 = true
|
||||
gain = -8.8
|
||||
comp = 3.2
|
||||
comp.knob = 3.2
|
||||
|
||||
[strip-1]
|
||||
label = "PhysStrip1"
|
||||
B1 = true
|
||||
gate = 4.1
|
||||
gate.knob = 4.1
|
||||
|
||||
[strip-2]
|
||||
label = "PhysStrip2"
|
||||
@@ -47,7 +47,7 @@ mono = true
|
||||
|
||||
[bus-2]
|
||||
label = "PhysBus2"
|
||||
eq = true
|
||||
eq.on = true
|
||||
|
||||
[bus-3]
|
||||
label = "PhysBus3"
|
||||
@@ -59,7 +59,7 @@ mode = "composite"
|
||||
|
||||
[bus-5]
|
||||
label = "VirtBus0"
|
||||
eq_ab = true
|
||||
eq.ab = true
|
||||
|
||||
[bus-6]
|
||||
label = "VirtBus1"
|
||||
|
||||
12
configs/potato/extender.toml
Normal file
12
configs/potato/extender.toml
Normal 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
13
examples/gui/README.md
Normal 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.
|
||||
117
examples/gui/__main__.py
Normal file
117
examples/gui/__main__.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
import vban_cmd
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
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():
|
||||
KIND_ID = 'banana'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, ldirty=True, **conn) as vban:
|
||||
app = App(vban)
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
53
examples/obs/README.md
Normal file
53
examples/obs/README.md
Normal 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+.
|
||||
110
examples/obs/__main__.py
Normal file
110
examples/obs/__main__.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import os
|
||||
import threading
|
||||
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',
|
||||
'propagate': False,
|
||||
}
|
||||
},
|
||||
'root': {'handlers': ['stream'], 'level': 'WARNING'},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Observer:
|
||||
def __init__(self, vban, stop_event):
|
||||
self._vban = vban
|
||||
self._stop_event = stop_event
|
||||
self._client = obsws.EventClient()
|
||||
self._client.callback.register(
|
||||
(
|
||||
self.on_current_program_scene_changed,
|
||||
self.on_exit_started,
|
||||
)
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||
self._client.disconnect()
|
||||
|
||||
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):
|
||||
scene = data.scene_name
|
||||
print(f'Switched to scene {scene}')
|
||||
match scene:
|
||||
case 'START':
|
||||
self.on_start()
|
||||
case 'BRB':
|
||||
self.on_brb()
|
||||
case 'END':
|
||||
self.on_end()
|
||||
case 'LIVE':
|
||||
self.on_live()
|
||||
|
||||
def on_exit_started(self, _):
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def main():
|
||||
KIND_ID = 'potato'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, **conn) as vban:
|
||||
stop_event = threading.Event()
|
||||
|
||||
with Observer(vban, stop_event):
|
||||
stop_event.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
7
examples/obs/setup.py
Normal file
7
examples/obs/setup.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='obs',
|
||||
description='OBS Example',
|
||||
install_requires=['obsws-python'],
|
||||
)
|
||||
29
examples/observer/README.md
Normal file
29
examples/observer/README.md
Normal 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.
|
||||
@@ -1,47 +1,41 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import vban_cmd
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
class Observer:
|
||||
|
||||
class App:
|
||||
def __init__(self, vban):
|
||||
self.vban = vban
|
||||
# register your app as event observer
|
||||
self.vban.subject.add(self)
|
||||
# add level updates, since they are disabled by default.
|
||||
self.vm.event.add("ldirty")
|
||||
self.vban.observer.add(self)
|
||||
|
||||
# define an 'on_update' callback function to receive event updates
|
||||
def on_update(self, subject):
|
||||
if subject == "pdirty":
|
||||
print("pdirty!")
|
||||
elif subject == "ldirty":
|
||||
info = (
|
||||
f"[{self.vban.bus[0]} {self.vban.bus[0].levels.isdirty}]",
|
||||
f"[{self.vban.bus[1]} {self.vban.bus[1].levels.isdirty}]",
|
||||
f"[{self.vban.bus[2]} {self.vban.bus[2].levels.isdirty}]",
|
||||
f"[{self.vban.bus[3]} {self.vban.bus[3].levels.isdirty}]",
|
||||
f"[{self.vban.bus[4]} {self.vban.bus[4].levels.isdirty}]",
|
||||
f"[{self.vban.bus[5]} {self.vban.bus[5].levels.isdirty}]",
|
||||
f"[{self.vban.bus[6]} {self.vban.bus[6].levels.isdirty}]",
|
||||
f"[{self.vban.bus[7]} {self.vban.bus[7].levels.isdirty}]",
|
||||
)
|
||||
print(" ".join(info))
|
||||
def on_update(self, event):
|
||||
if event == 'pdirty':
|
||||
print('pdirty!')
|
||||
elif event == 'ldirty':
|
||||
for bus in self.vban.bus:
|
||||
if bus.levels.isdirty:
|
||||
print(bus, bus.levels.all)
|
||||
|
||||
|
||||
def main():
|
||||
with vban_cmd.api(kind_id, **opts) as vban:
|
||||
obs = Observer(vban)
|
||||
|
||||
while cmd := input("Press <Enter> to exit\n"):
|
||||
if not cmd:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kind_id = "potato"
|
||||
opts = {
|
||||
"ip": "<ip address>",
|
||||
"streamname": "Command1",
|
||||
"port": 6980,
|
||||
KIND_ID = 'banana'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, pdirty=True, ldirty=True, **conn) as vban:
|
||||
App(vban)
|
||||
|
||||
while _ := input('Press <Enter> to exit\n'):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
513
poetry.lock
generated
513
poetry.lock
generated
@@ -1,294 +1,363 @@
|
||||
[[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.*"
|
||||
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "22.1.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "22.6.0"
|
||||
description = "The uncompromising code formatter."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2"
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=8.0.0"
|
||||
mypy-extensions = ">=0.4.3"
|
||||
pathspec = ">=0.9.0"
|
||||
platformdirs = ">=2"
|
||||
|
||||
[package.extras]
|
||||
colorama = ["colorama (>=0.4.3)"]
|
||||
d = ["aiohttp (>=3.7.4)"]
|
||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
||||
uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.3"
|
||||
description = "Composable command line interface toolkit"
|
||||
category = "dev"
|
||||
name = "cachetools"
|
||||
version = "5.5.0"
|
||||
description = "Extensible memoizing collections and decorators"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
|
||||
{file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "5.2.0"
|
||||
description = "Universal encoding detector for Python 3"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
|
||||
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "dev"
|
||||
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"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.9"
|
||||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
|
||||
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.2"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
|
||||
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.16.1"
|
||||
description = "A platform independent file lock."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
|
||||
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
|
||||
typing = ["typing-extensions (>=4.12.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "1.1.1"
|
||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||
category = "dev"
|
||||
version = "2.0.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.10.1"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.1,<4.0"
|
||||
|
||||
[package.extras]
|
||||
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
|
||||
requirements_deprecated_finder = ["pipreqs", "pip-api"]
|
||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
||||
plugins = ["setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "21.3"
|
||||
version = "24.2"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.9.0"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "2.5.2"
|
||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
version = "4.3.6"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
|
||||
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
|
||||
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
|
||||
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
|
||||
type = ["mypy (>=1.11.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.0.0"
|
||||
version = "1.5.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
category = "dev"
|
||||
name = "pyenv-inspect"
|
||||
version = "0.4.0"
|
||||
description = "An auxiliary library for the virtualenv-pyenv and tox-pyenv-redux plugins"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyenv-inspect-0.4.0.tar.gz", hash = "sha256:ec429d1d81b67ab0b08a0408414722a79d24fd1845a5b264267e44e19d8d60f0"},
|
||||
{file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.0.9"
|
||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||
category = "dev"
|
||||
name = "pyproject-api"
|
||||
version = "1.8.0"
|
||||
description = "API to interact with the python pyproject.toml based projects"
|
||||
optional = false
|
||||
python-versions = ">=3.6.8"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"},
|
||||
{file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=24.1"
|
||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
diagrams = ["railroad-diagrams", "jinja2"]
|
||||
docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.1.2"
|
||||
version = "8.3.4"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
|
||||
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
py = ">=1.8.2"
|
||||
tomli = ">=1.0.0"
|
||||
pluggy = ">=1.5,<2"
|
||||
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-randomly"
|
||||
version = "3.12.0"
|
||||
version = "3.16.0"
|
||||
description = "Pytest plugin to randomly order tests and control random.seed."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"},
|
||||
{file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-repeat"
|
||||
version = "0.9.1"
|
||||
description = "pytest plugin for repeating tests"
|
||||
category = "dev"
|
||||
name = "ruff"
|
||||
version = "0.9.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
|
||||
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
|
||||
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
|
||||
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
|
||||
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
version = "2.2.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
|
||||
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
|
||||
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tox"
|
||||
version = "4.23.2"
|
||||
description = "tox is a generic virtualenv management and test command line tool"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38"},
|
||||
{file = "tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cachetools = ">=5.5"
|
||||
chardet = ">=5.2"
|
||||
colorama = ">=0.4.6"
|
||||
filelock = ">=3.16.1"
|
||||
packaging = ">=24.1"
|
||||
platformdirs = ">=4.3.6"
|
||||
pluggy = ">=1.5"
|
||||
pyproject-api = ">=1.8"
|
||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||
typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
|
||||
virtualenv = ">=20.26.6"
|
||||
|
||||
[package.extras]
|
||||
test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.29.0"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "virtualenv-20.29.0-py3-none-any.whl", hash = "sha256:c12311863497992dc4b8644f8ea82d3b35bb7ef8ee82e6630d76d0197c39baf9"},
|
||||
{file = "virtualenv-20.29.0.tar.gz", hash = "sha256:6345e1ff19d4b1296954cee076baaf58ff2a12a84a338c62b02eda39f20aa982"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
distlib = ">=0.3.7,<1"
|
||||
filelock = ">=3.12.2,<4"
|
||||
platformdirs = ">=3.9.1,<5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv-pyenv"
|
||||
version = "0.5.0"
|
||||
description = "A virtualenv Python discovery plugin for pyenv-installed interpreters"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "virtualenv-pyenv-0.5.0.tar.gz", hash = "sha256:7b0e5fe3dfbdf484f4cf9b01e1f98111e398db6942237910f666356e6293597f"},
|
||||
{file = "virtualenv_pyenv-0.5.0-py3-none-any.whl", hash = "sha256:21750247e36c55b3c547cfdeb08f51a3867fe7129922991a4f9c96980c0a4a5d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyenv-inspect = ">=0.4,<0.5"
|
||||
virtualenv = "*"
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "13366a58ff2f3fa0de2cb1e3de2f66fff612610fa66bb909201ebaa434cce014"
|
||||
|
||||
[metadata.files]
|
||||
atomicwrites = []
|
||||
attrs = []
|
||||
black = [
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
|
||||
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
|
||||
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
|
||||
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
|
||||
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
|
||||
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
|
||||
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
|
||||
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
|
||||
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
|
||||
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
|
||||
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
|
||||
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
|
||||
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
|
||||
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
|
||||
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
||||
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||
]
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
isort = [
|
||||
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
|
||||
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
|
||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
|
||||
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
|
||||
]
|
||||
platformdirs = [
|
||||
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
|
||||
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
||||
]
|
||||
py = [
|
||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
|
||||
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
|
||||
]
|
||||
pytest-randomly = [
|
||||
{file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"},
|
||||
{file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"},
|
||||
]
|
||||
pytest-repeat = []
|
||||
tomli = [
|
||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10"
|
||||
content-hash = "13fc9d0eb15d5fc09b54c1c8cd8f528b260259e97ee6813b50ab4724c35d6677"
|
||||
|
||||
145
pyproject.toml
145
pyproject.toml
@@ -1,23 +1,138 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "vban-cmd"
|
||||
version = "1.3.2"
|
||||
version = "2.10.1"
|
||||
description = "Python interface for the VBAN RT Packet Service (Sendtext)"
|
||||
authors = ["onyx-and-iris <code@onyxandiris.online>"]
|
||||
license = "MIT"
|
||||
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/onyx-and-iris/vban-cmd-python"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["tomli (>=2.0.1,<3.0) ; python_version < '3.11'"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
[tool.poetry.requires-plugins]
|
||||
poethepoet = ">=0.42.0"
|
||||
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.2"
|
||||
pytest-randomly = "^3.12.0"
|
||||
pytest-repeat = "^0.9.1"
|
||||
black = "^22.3.0"
|
||||
isort = "^5.10.1"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.4"
|
||||
pytest-randomly = "^3.16.0"
|
||||
ruff = "^0.9.2"
|
||||
tox = "^4.23.2"
|
||||
virtualenv-pyenv = "^0.5.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poe]
|
||||
envfile = ".env"
|
||||
|
||||
[tool.poe.tasks]
|
||||
gui.script = "scripts:ex_gui"
|
||||
obs.script = "scripts:ex_obs"
|
||||
observer.script = "scripts:ex_observer"
|
||||
test-basic.script = "scripts:test_basic"
|
||||
test-banana.script = "scripts:test_banana"
|
||||
test-potato.script = "scripts:test_potato"
|
||||
test-all.script = "scripts:test_all"
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
envlist = py310,py311,py312,py313
|
||||
|
||||
[testenv]
|
||||
passenv = *
|
||||
setenv = VIRTUALENV_DISCOVERY=pyenv
|
||||
allowlist_externals = poetry
|
||||
commands =
|
||||
poetry install -v
|
||||
poetry run pytest tests/
|
||||
|
||||
|
||||
[testenv:obs]
|
||||
setenv = VIRTUALENV_DISCOVERY=pyenv
|
||||
allowlist_externals = poetry
|
||||
deps = obsws-python
|
||||
commands =
|
||||
poetry install -v --without dev
|
||||
poetry run python examples/obs/
|
||||
"""
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"venv",
|
||||
]
|
||||
|
||||
# Same as Black.
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
# Assume Python 3.10
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
||||
# McCabe complexity (`C901`) by default.
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
ignore = []
|
||||
|
||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
# Allow unused variables when underscore-prefixed.
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
|
||||
[tool.ruff.format]
|
||||
# Unlike Black, use single quotes for strings.
|
||||
quote-style = "single"
|
||||
|
||||
# Like Black, indent with spaces, rather than tabs.
|
||||
indent-style = "space"
|
||||
|
||||
# Like Black, respect magic trailing commas.
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
# Enable auto-formatting of code examples in docstrings. Markdown,
|
||||
# reStructuredText code/literal blocks and doctests are all supported.
|
||||
#
|
||||
# This is currently disabled by default, but it is planned for this
|
||||
# to be opt-out in the future.
|
||||
docstring-code-format = false
|
||||
|
||||
# Set the line length limit used when formatting code snippets in
|
||||
# docstrings.
|
||||
#
|
||||
# This only has an effect when the `docstring-code-format` setting is
|
||||
# enabled.
|
||||
docstring-code-line-length = "dynamic"
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 10
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["E402", "F401"]
|
||||
|
||||
35
scripts.py
Normal file
35
scripts.py
Normal file
@@ -0,0 +1,35 @@
|
||||
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():
|
||||
subprocess.run(['tox', 'r', '-e', 'obs'])
|
||||
|
||||
|
||||
def ex_observer():
|
||||
scriptpath = Path.cwd() / 'examples' / 'observer' / '.'
|
||||
subprocess.run([sys.executable, str(scriptpath)])
|
||||
|
||||
|
||||
def test_basic():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'basic'})
|
||||
|
||||
|
||||
def test_banana():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'banana'})
|
||||
|
||||
|
||||
def test_potato():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'potato'})
|
||||
|
||||
|
||||
def test_all():
|
||||
steps = [test_basic, test_banana, test_potato]
|
||||
[step() for step in steps]
|
||||
@@ -1,25 +1,23 @@
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
|
||||
# let's keep things random
|
||||
kind_id = random.choice(tuple(kind_id.name.lower() for kind_id in KindId))
|
||||
# get KIND from environment, if not set default to potato
|
||||
KIND_ID = os.environ.get('KIND', 'potato')
|
||||
|
||||
opts = {
|
||||
"ip": "ws.local",
|
||||
"streamname": "workstation",
|
||||
"port": 6990,
|
||||
"bps": 0,
|
||||
"sync": True,
|
||||
'host': os.getenv('VBANCMD_HOST', 'localhost'),
|
||||
'streamname': os.getenv('VBANCMD_STREAMNAME', 'Command1'),
|
||||
'port': int(os.getenv('VBANCMD_PORT', 6980)),
|
||||
}
|
||||
|
||||
vbans = {kind.name: vban_cmd.api(kind.name, **opts) for kind in kinds_all}
|
||||
tests = vbans[kind_id]
|
||||
kind = kindmap(kind_id)
|
||||
vban = vban_cmd.api(KIND_ID, **opts)
|
||||
kind = kindmap(KIND_ID)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -41,10 +39,10 @@ data = Data()
|
||||
|
||||
|
||||
def setup_module():
|
||||
print(f"\nRunning tests for kind [{data.name}]\n", file=sys.stdout)
|
||||
tests.login()
|
||||
tests.command.reset()
|
||||
print(f'\nRunning tests for kind [{data.name}]\n', file=sys.stdout)
|
||||
vban.login()
|
||||
vban.command.reset()
|
||||
|
||||
|
||||
def teardown_module():
|
||||
tests.logout()
|
||||
vban.logout()
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -1,7 +1,7 @@
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--run-slow",
|
||||
action="store_true",
|
||||
'--run-slow',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help="Run slow tests",
|
||||
help='Run slow tests',
|
||||
)
|
||||
|
||||
@@ -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 |
@@ -11,7 +11,7 @@ Function RunTests {
|
||||
$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"
|
||||
}
|
||||
@@ -25,7 +25,10 @@ Function Get-TimeStamp {
|
||||
if ($MyInvocation.InvocationName -ne ".") {
|
||||
Invoke-Expression ".\.venv\Scripts\Activate.ps1"
|
||||
|
||||
@("potato") | ForEach-Object {
|
||||
$env:KIND = $_
|
||||
RunTests
|
||||
}
|
||||
|
||||
Invoke-Expression "deactivate"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests import data, tests
|
||||
from tests import data, vban
|
||||
|
||||
|
||||
class TestSetAndGetBoolHigher:
|
||||
@@ -12,18 +12,27 @@ class TestSetAndGetBoolHigher:
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
tests.apply_config("example")
|
||||
|
||||
def test_it_tests_config_string(self):
|
||||
assert "PhysStrip" in tests.strip[data.phys_in].label
|
||||
assert "VirtStrip" in tests.strip[data.virt_in].label
|
||||
|
||||
def test_it_tests_config_bool(self):
|
||||
assert tests.strip[0].A1 == True
|
||||
vban.apply_config('example')
|
||||
time.sleep(0.1)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"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_string(self):
|
||||
assert 'PhysStrip' in vban.strip[data.phys_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):
|
||||
assert vban.strip[0].A1
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--run-slow')",
|
||||
reason='Only run when --run-slow is given',
|
||||
)
|
||||
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
37
tests/test_errors.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import vban_cmd
|
||||
from tests import vban
|
||||
|
||||
|
||||
class TestErrors:
|
||||
__test__ = True
|
||||
|
||||
def test_it_tests_an_unknown_kind(self):
|
||||
with pytest.raises(
|
||||
vban_cmd.error.VBANCMDError,
|
||||
match="Unknown Voicemeeter kind 'unknown_kind'",
|
||||
):
|
||||
vban_cmd.api('unknown_kind')
|
||||
|
||||
def test_it_tests_an_unknown_config_name(self):
|
||||
EXPECTED_MSG = '\n'.join(
|
||||
(
|
||||
"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)
|
||||
@@ -1,43 +1,55 @@
|
||||
import pytest
|
||||
|
||||
from tests import data, tests
|
||||
from tests import data, vban
|
||||
|
||||
|
||||
class TestRemoteFactories:
|
||||
__test__ = True
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "basic",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'basic',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_basic(self):
|
||||
assert hasattr(tests, "strip")
|
||||
assert hasattr(tests, "bus")
|
||||
assert hasattr(tests, "command")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(tests.strip) == 3
|
||||
assert len(tests.bus) == 2
|
||||
assert len(vban.strip) == 3
|
||||
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(
|
||||
data.name != "banana",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'banana',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_banana(self):
|
||||
assert hasattr(tests, "strip")
|
||||
assert hasattr(tests, "bus")
|
||||
assert hasattr(tests, "command")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(tests.strip) == 5
|
||||
assert len(tests.bus) == 5
|
||||
assert len(vban.strip) == 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(
|
||||
data.name != "potato",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'potato',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_potato(self):
|
||||
assert hasattr(tests, "strip")
|
||||
assert hasattr(tests, "bus")
|
||||
assert hasattr(tests, "command")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(tests.strip) == 8
|
||||
assert len(tests.bus) == 8
|
||||
assert len(vban.strip) == 8
|
||||
assert len(vban.bus) == 8
|
||||
assert len(vban.button) == 80
|
||||
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9
|
||||
|
||||
@@ -1,82 +1,80 @@
|
||||
import pytest
|
||||
|
||||
from tests import data, tests
|
||||
from tests import data, vban
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, True])
|
||||
@pytest.mark.parametrize('value', [False, True])
|
||||
class TestSetAndGetBoolHigher:
|
||||
__test__ = True
|
||||
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_in, "mute"),
|
||||
(data.virt_in, "solo"),
|
||||
(data.phys_in, 'mute'),
|
||||
(data.virt_in, 'solo'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name == "banana",
|
||||
reason="Only test if logged into Basic or Potato version",
|
||||
data.name == 'banana',
|
||||
reason='Only test if logged into Basic or Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_in, "mc"),
|
||||
(data.phys_in, 'mc'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_out, "eq"),
|
||||
(data.phys_out, "mute"),
|
||||
(data.virt_out, "eq_ab"),
|
||||
(data.virt_out, "sel"),
|
||||
(data.phys_out, 'mute'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
|
||||
setattr(tests.bus[index], param, value)
|
||||
assert getattr(tests.bus[index], param) == value
|
||||
assert hasattr(vban.bus[index], param)
|
||||
setattr(vban.bus[index], param, value)
|
||||
assert getattr(vban.bus[index], param) == value
|
||||
|
||||
""" bus modes tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_out, "normal"),
|
||||
(data.phys_out, "amix"),
|
||||
(data.phys_out, "rearonly"),
|
||||
(data.virt_out, "normal"),
|
||||
(data.virt_out, "upmix41"),
|
||||
(data.virt_out, "composite"),
|
||||
(data.phys_out, 'normal'),
|
||||
(data.phys_out, 'amix'),
|
||||
(data.phys_out, 'rearonly'),
|
||||
(data.virt_out, 'normal'),
|
||||
(data.virt_out, 'upmix41'),
|
||||
(data.virt_out, 'composite'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
|
||||
def test_it_sets_and_gets_bus_mode_bool_params(self, index, param, value):
|
||||
# here it only makes sense to set/get bus modes as True
|
||||
if not value:
|
||||
value = True
|
||||
setattr(tests.bus[index].mode, param, value)
|
||||
assert getattr(tests.bus[index].mode, param) == value
|
||||
setattr(vban.bus[index].mode, param, value)
|
||||
assert getattr(vban.bus[index].mode, param) == value
|
||||
|
||||
""" command tests """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param",
|
||||
[("lock")],
|
||||
'param',
|
||||
[('lock')],
|
||||
)
|
||||
def test_it_sets_command_bool_params(self, param, value):
|
||||
setattr(tests.command, param, value)
|
||||
setattr(vban.command, param, value)
|
||||
|
||||
|
||||
class TestSetAndGetIntHigher:
|
||||
@@ -87,15 +85,15 @@ class TestSetAndGetIntHigher:
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param,value",
|
||||
'index,param,value',
|
||||
[
|
||||
(data.virt_in, "k", 0),
|
||||
(data.virt_in, "k", 4),
|
||||
(data.virt_in, 'k', 0),
|
||||
(data.virt_in, 'k', 4),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
|
||||
class TestSetAndGetFloatHigher:
|
||||
@@ -104,31 +102,33 @@ class TestSetAndGetFloatHigher:
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param,value",
|
||||
'index,param,value',
|
||||
[
|
||||
(data.phys_in, "gain", -3.6),
|
||||
(data.phys_in, "gain", 3.6),
|
||||
(data.virt_in, "gain", -5.8),
|
||||
(data.virt_in, "gain", 5.8),
|
||||
(data.phys_in, 'gain', -3.6),
|
||||
(data.phys_in, 'gain', 3.6),
|
||||
(data.virt_in, 'gain', -5.8),
|
||||
(data.virt_in, 'gain', 5.8),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_float_params(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,value",
|
||||
'index,value',
|
||||
[(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):
|
||||
assert len(tests.strip[index].levels.prefader) == value
|
||||
def test_it_gets_strip_prefader_levels_and_compares_length_of_array(
|
||||
self, index, value
|
||||
):
|
||||
assert len(vban.strip[index].levels.prefader) == value
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "potato",
|
||||
reason="Only test if logged into Potato version",
|
||||
data.name != 'potato',
|
||||
reason='Only test if logged into Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index, j, value",
|
||||
'index, j, value',
|
||||
[
|
||||
(data.phys_in, 0, -20.7),
|
||||
(data.virt_in, 3, -60),
|
||||
@@ -137,61 +137,96 @@ class TestSetAndGetFloatHigher:
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value):
|
||||
tests.strip[index].gainlayer[j].gain = value
|
||||
assert tests.strip[index].gainlayer[j].gain == value
|
||||
vban.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 """
|
||||
|
||||
@pytest.mark.skip(reason='Requires RT Packet NBS 1')
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
'index, param, value',
|
||||
[
|
||||
(data.virt_in, "treble", -1.6),
|
||||
(data.virt_in, "mid", 5.8),
|
||||
(data.virt_in, "bass", -8.1),
|
||||
(data.virt_in, 'treble', -1.6),
|
||||
(data.virt_in, 'mid', 5.8),
|
||||
(data.virt_in, 'bass', -8.1),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
[(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)],
|
||||
'index, param, value',
|
||||
[(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):
|
||||
setattr(tests.bus[index], param, value)
|
||||
assert getattr(tests.bus[index], param) == value
|
||||
setattr(vban.bus[index], param, value)
|
||||
assert getattr(vban.bus[index], param) == value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,value",
|
||||
'index,value',
|
||||
[(data.phys_out, 8), (data.virt_out, 8)],
|
||||
)
|
||||
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
|
||||
assert len(tests.bus[index].levels.all) == value
|
||||
def test_it_gets_bus_levels_and_compares_length_of_array(self, index, value):
|
||||
assert len(vban.bus[index].levels.all) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["test0", "test1"])
|
||||
@pytest.mark.parametrize('value', ['test0', 'test1'])
|
||||
class TestSetAndGetStringHigher:
|
||||
__test__ = True
|
||||
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param",
|
||||
[(data.phys_in, "label"), (data.virt_in, "label")],
|
||||
'index, param',
|
||||
[(data.phys_in, 'label'), (data.virt_in, 'label')],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_string_params(self, index, param, value):
|
||||
setattr(tests.strip[index], param, value)
|
||||
assert getattr(tests.strip[index], param) == value
|
||||
setattr(vban.strip[index], param, value)
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param",
|
||||
[(data.phys_out, "label"), (data.virt_out, "label")],
|
||||
'index, param',
|
||||
[(data.phys_out, 'label'), (data.virt_out, 'label')],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_string_params(self, index, param, value):
|
||||
setattr(tests.bus[index], param, value)
|
||||
assert getattr(tests.bus[index], param) == value
|
||||
setattr(vban.bus[index], param, value)
|
||||
assert getattr(vban.bus[index], param) == value
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from vban_cmd import kinds
|
||||
|
||||
from tests import data, tests
|
||||
from tests import data, vban
|
||||
from vban_cmd import kinds
|
||||
|
||||
|
||||
class TestPublicPacketLower:
|
||||
@@ -11,31 +9,26 @@ class TestPublicPacketLower:
|
||||
|
||||
"""Tests for a valid rt data packet"""
|
||||
|
||||
def test_it_gets_an_rt_data_packet(self):
|
||||
assert tests.public_packet.voicemeetertype in (
|
||||
kind.name for kind in kinds.kinds_all
|
||||
def test_it_gets_an_rt0_data_packet(self):
|
||||
assert vban.public_packets[0].voicemeetertype in (
|
||||
kind.name for kind in 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:
|
||||
__test__ = True
|
||||
|
||||
"""Tests set_rt"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kls,index,param",
|
||||
'kls,index,param',
|
||||
[
|
||||
("strip", data.phys_in, "mute"),
|
||||
("bus", data.virt_out, "mono"),
|
||||
('strip', data.phys_in, 'mute'),
|
||||
('bus', data.virt_out, 'mono'),
|
||||
],
|
||||
)
|
||||
def test_it_sends_a_text_request(self, kls, index, param, value):
|
||||
tests._set_rt(f"{kls}[{index}]", param, value)
|
||||
time.sleep(0.02)
|
||||
target = getattr(tests, kls)[index]
|
||||
vban._set_rt(f'{kls}[{index}].{param}', value)
|
||||
target = getattr(vban, kls)[index]
|
||||
assert getattr(target, param) == bool(value)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .factory import request_vbancmd_obj as api
|
||||
|
||||
__ALL__ = ["api"]
|
||||
__ALL__ = ['api']
|
||||
|
||||
164
vban_cmd/base.py
164
vban_cmd/base.py
@@ -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()
|
||||
144
vban_cmd/bus.py
144
vban_cmd/bus.py
@@ -1,16 +1,10 @@
|
||||
import abc
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from enum import IntEnum
|
||||
from typing import Union
|
||||
|
||||
from .enums import NBS, BusModes
|
||||
from .iremote import IRemote
|
||||
from .meta import bus_mode_prop, channel_bool_prop, channel_label_prop
|
||||
|
||||
BusModes = IntEnum(
|
||||
"BusModes",
|
||||
"normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly",
|
||||
start=0,
|
||||
)
|
||||
from .meta import bus_mode_prop, channel_bool_prop, channel_int_prop, channel_label_prop
|
||||
|
||||
|
||||
class Bus(IRemote):
|
||||
@@ -20,38 +14,55 @@ class Bus(IRemote):
|
||||
Defines concrete implementation for bus
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}]"
|
||||
return f'bus[{self.index}]'
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
def fget():
|
||||
val = self.public_packet.busgain[self.index]
|
||||
if val < 10000:
|
||||
return -val
|
||||
elif val == ((1 << 16) - 1):
|
||||
return 0
|
||||
val = self.getter('gain')
|
||||
if val:
|
||||
return round(val, 2)
|
||||
else:
|
||||
return ((1 << 16) - 1) - val
|
||||
|
||||
val = self.getter("gain")
|
||||
if val is None:
|
||||
val = fget() * 0.01
|
||||
return round(val, 1)
|
||||
return self.public_packets[NBS.zero].busgain[self.index]
|
||||
|
||||
@gain.setter
|
||||
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):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
@property
|
||||
def device(self) -> str:
|
||||
@@ -64,7 +75,7 @@ class PhysicalBus(Bus):
|
||||
|
||||
class VirtualBus(Bus):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
|
||||
class BusLevel(IRemote):
|
||||
@@ -79,14 +90,13 @@ class BusLevel(IRemote):
|
||||
def getter(self):
|
||||
"""Returns a tuple of level values for the channel."""
|
||||
|
||||
return tuple(
|
||||
round(-i * 0.01, 1)
|
||||
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]]
|
||||
)
|
||||
if not self._remote.stopped() and self._remote.event.ldirty:
|
||||
return self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
|
||||
return self.public_packets[NBS.zero].levels.bus[self.range[0] : self.range[-1]]
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}]"
|
||||
return f'bus[{self.index}]'
|
||||
|
||||
@property
|
||||
def all(self) -> tuple:
|
||||
@@ -107,37 +117,51 @@ class BusLevel(IRemote):
|
||||
def _make_bus_mode_mixin():
|
||||
"""Creates a mixin of Bus Modes."""
|
||||
|
||||
mode_names = [
|
||||
'normal',
|
||||
'amix',
|
||||
'repeat',
|
||||
'bmix',
|
||||
'composite',
|
||||
'tvmix',
|
||||
'upmix21',
|
||||
'upmix41',
|
||||
'upmix61',
|
||||
'centeronly',
|
||||
'lfeonly',
|
||||
'rearonly',
|
||||
]
|
||||
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}].mode"
|
||||
return f'bus[{self.index}].mode'
|
||||
|
||||
def get(self):
|
||||
time.sleep(0.01)
|
||||
for i, val in enumerate(
|
||||
[
|
||||
self.amix,
|
||||
self.bmix,
|
||||
self.repeat,
|
||||
self.composite,
|
||||
self.tvmix,
|
||||
self.upmix21,
|
||||
self.upmix41,
|
||||
self.upmix61,
|
||||
self.centeronly,
|
||||
self.lfeonly,
|
||||
self.rearonly,
|
||||
"""Get current bus mode using ChannelState for clean bit extraction."""
|
||||
mode_cache_items = [
|
||||
(k, v)
|
||||
for k, v in self._remote.cache.items()
|
||||
if k.startswith(f'{self.identifier}.') and v == 1
|
||||
]
|
||||
):
|
||||
if val:
|
||||
return BusModes(i + 1).name
|
||||
return "normal"
|
||||
|
||||
if mode_cache_items:
|
||||
latest_cached = mode_cache_items[-1][0]
|
||||
mode_name = latest_cached.split('.')[-1]
|
||||
return mode_name
|
||||
|
||||
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||
|
||||
# Extract bus mode from bits 4-7 (mask 0xF0, shift right by 4)
|
||||
mode_value = (bus_state._state & 0x000000F0) >> 4
|
||||
|
||||
return mode_names[mode_value] if mode_value < len(mode_names) else 'normal'
|
||||
|
||||
return type(
|
||||
"BusModeMixin",
|
||||
'BusModeMixin',
|
||||
(IRemote,),
|
||||
{
|
||||
"identifier": property(identifier),
|
||||
'identifier': property(identifier),
|
||||
**{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
|
||||
"get": get,
|
||||
'get': get,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,15 +175,15 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
|
||||
BUS_cls = PhysicalBus if phys_bus else VirtualBus
|
||||
BUSMODEMIXIN_cls = _make_bus_mode_mixin()
|
||||
return type(
|
||||
f"{BUS_cls.__name__}{remote.kind}",
|
||||
f'{BUS_cls.__name__}{remote.kind}',
|
||||
(BUS_cls,),
|
||||
{
|
||||
"levels": BusLevel(remote, i),
|
||||
"mode": BUSMODEMIXIN_cls(remote, i),
|
||||
**{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(),
|
||||
'eq': BusEQ.make(remote, i),
|
||||
'levels': BusLevel(remote, i),
|
||||
'mode': BUSMODEMIXIN_cls(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ('mute',)},
|
||||
**{param: channel_int_prop(param) for param in ('mono',)},
|
||||
'label': channel_label_prop(),
|
||||
},
|
||||
)(remote, i)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .error import VMCMDErrors
|
||||
from .iremote import IRemote
|
||||
from .meta import action_prop
|
||||
from .meta import action_fn
|
||||
|
||||
|
||||
class Command(IRemote):
|
||||
@@ -18,31 +17,30 @@ class Command(IRemote):
|
||||
Returns a Command class of a kind.
|
||||
"""
|
||||
CMD_cls = type(
|
||||
f"Command{remote.kind}",
|
||||
f'Command{remote.kind}',
|
||||
(cls,),
|
||||
{
|
||||
**{
|
||||
param: action_prop(param)
|
||||
for param in ["show", "shutdown", "restart"]
|
||||
param: action_fn(param) for param in ['show', 'shutdown', 'restart']
|
||||
},
|
||||
"hide": action_prop("show", val=0),
|
||||
'hide': action_fn('show', val=0),
|
||||
},
|
||||
)
|
||||
return CMD_cls(remote)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return "Command"
|
||||
return 'command'
|
||||
|
||||
def set_showvbanchat(self, val: bool):
|
||||
self.setter("DialogShow.VBANCHAT", 1 if val else 0)
|
||||
self.setter('DialogShow.VBANCHAT', 1 if val else 0)
|
||||
|
||||
showvbanchat = property(fset=set_showvbanchat)
|
||||
|
||||
def set_lock(self, val: bool):
|
||||
self.setter("lock", 1 if val else 0)
|
||||
self.setter('lock', 1 if val else 0)
|
||||
|
||||
lock = property(fset=set_lock)
|
||||
|
||||
def reset(self):
|
||||
self._remote.apply_config("reset")
|
||||
self._remote.apply_config('reset')
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import itertools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
from .error import VBANCMDError
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
|
||||
from .kinds import request_kind_map as kindmap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TOMLStrBuilder:
|
||||
"""builds a config profile, as a string, for the toml parser"""
|
||||
@@ -12,66 +20,73 @@ class TOMLStrBuilder:
|
||||
def __init__(self, kind):
|
||||
self.kind = kind
|
||||
self.higher = itertools.chain(
|
||||
[f"strip-{i}" for i in range(kind.num_strip)],
|
||||
[f"bus-{i}" for i in range(kind.num_bus)],
|
||||
[f'strip-{i}' for i in range(kind.num_strip)],
|
||||
[f'bus-{i}' for i in range(kind.num_bus)],
|
||||
)
|
||||
|
||||
def init_config(self, profile=None):
|
||||
self.virt_strip_params = (
|
||||
[
|
||||
"mute = false",
|
||||
"mono = false",
|
||||
"solo = false",
|
||||
"gain = 0.0",
|
||||
'mute = false',
|
||||
'mono = false',
|
||||
'solo = false',
|
||||
'gain = 0.0',
|
||||
]
|
||||
+ [f"A{i} = false" for i in range(1, self.kind.phys_out + 1)]
|
||||
+ [f"B{i} = false" for i in range(1, self.kind.virt_out + 1)]
|
||||
+ [f'A{i} = false' for i in range(1, self.kind.phys_out + 1)]
|
||||
+ [f'B{i} = false' for i in range(1, self.kind.virt_out + 1)]
|
||||
)
|
||||
self.phys_strip_params = self.virt_strip_params + [
|
||||
"comp = 0.0",
|
||||
"gate = 0.0",
|
||||
'comp.knob = 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()
|
||||
|
||||
def reset_config(self):
|
||||
self.phys_strip_params = list(
|
||||
map(lambda x: x.replace("B1 = false", "B1 = true"), self.phys_strip_params)
|
||||
map(lambda x: x.replace('B1 = false', 'B1 = true'), self.phys_strip_params)
|
||||
)
|
||||
self.virt_strip_params = list(
|
||||
map(lambda x: x.replace("A1 = false", "A1 = true"), self.virt_strip_params)
|
||||
map(lambda x: x.replace('A1 = false', 'A1 = true'), self.virt_strip_params)
|
||||
)
|
||||
|
||||
def build(self, profile="reset"):
|
||||
def build(self, profile='reset'):
|
||||
self.init_config(profile)
|
||||
toml_str = str()
|
||||
for eachclass in self.higher:
|
||||
toml_str += f"[{eachclass}]\n"
|
||||
toml_str += f'[{eachclass}]\n'
|
||||
toml_str = self.join(eachclass, toml_str)
|
||||
return toml_str
|
||||
|
||||
def join(self, eachclass, toml_str):
|
||||
kls, index = eachclass.split("-")
|
||||
kls, index = eachclass.split('-')
|
||||
match kls:
|
||||
case "strip":
|
||||
toml_str += ("\n").join(
|
||||
case 'strip':
|
||||
toml_str += ('\n').join(
|
||||
self.phys_strip_params
|
||||
if int(index) < self.kind.phys_in
|
||||
else self.virt_strip_params
|
||||
)
|
||||
case "bus":
|
||||
toml_str += ("\n").join(self.bus_bool)
|
||||
case 'bus':
|
||||
toml_str += ('\n').join(self.bus_params)
|
||||
case _:
|
||||
pass
|
||||
return toml_str + "\n"
|
||||
return toml_str + '\n'
|
||||
|
||||
|
||||
class TOMLDataExtractor:
|
||||
def __init__(self, file):
|
||||
self._data = dict()
|
||||
with open(file, "rb") as f:
|
||||
with open(file, 'rb') as f:
|
||||
self._data = tomllib.load(f)
|
||||
|
||||
@property
|
||||
@@ -89,10 +104,10 @@ def dataextraction_factory(file):
|
||||
|
||||
this opens the possibility for other parsers to be added
|
||||
"""
|
||||
if file.suffix == ".toml":
|
||||
if file.suffix == '.toml':
|
||||
extractor = TOMLDataExtractor
|
||||
else:
|
||||
raise ValueError("Cannot extract data from {}".format(file))
|
||||
raise ValueError('Cannot extract data from {}'.format(file))
|
||||
return extractor(file)
|
||||
|
||||
|
||||
@@ -118,6 +133,7 @@ class Loader(metaclass=SingletonType):
|
||||
|
||||
def __init__(self, kind):
|
||||
self._kind = kind
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self._configs = dict()
|
||||
self.defaults(kind)
|
||||
self.parser = None
|
||||
@@ -125,18 +141,25 @@ class Loader(metaclass=SingletonType):
|
||||
def defaults(self, kind):
|
||||
self.builder = TOMLStrBuilder(kind)
|
||||
toml_str = self.builder.build()
|
||||
self.register("reset", tomllib.loads(toml_str))
|
||||
self.register('reset', tomllib.loads(toml_str))
|
||||
|
||||
def parse(self, identifier, data):
|
||||
if identifier in self._configs:
|
||||
print(f"config file with name {identifier} already in memory, skipping..")
|
||||
return False
|
||||
self.logger.info(
|
||||
f'config file with name {identifier} already in memory, skipping..'
|
||||
)
|
||||
return
|
||||
try:
|
||||
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
|
||||
|
||||
def register(self, identifier, data=None):
|
||||
self._configs[identifier] = data if data else self.parser.data
|
||||
print(f"config {self.name}/{identifier} loaded into memory")
|
||||
self.logger.info(f'config {self.name}/{identifier} loaded into memory')
|
||||
|
||||
def deregister(self):
|
||||
self._configs.clear()
|
||||
@@ -159,17 +182,18 @@ def loader(kind):
|
||||
|
||||
returns configs loaded into memory
|
||||
"""
|
||||
logger_loader = logger.getChild('loader')
|
||||
loader = Loader(kind)
|
||||
|
||||
for path in (
|
||||
Path.cwd() / "configs" / kind.name,
|
||||
Path(__file__).parent / "configs" / kind.name,
|
||||
Path.home() / "Documents/Voicemeeter" / "configs" / kind.name,
|
||||
Path.cwd() / 'configs' / kind.name,
|
||||
Path.home() / '.config' / 'vban-cmd' / kind.name,
|
||||
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / kind.name,
|
||||
):
|
||||
if path.is_dir():
|
||||
print(f"Checking [{path}] for TOML config files:")
|
||||
for file in path.glob("*.toml"):
|
||||
identifier = file.with_suffix("").stem
|
||||
logger_loader.info(f'Checking [{path}] for TOML config files:')
|
||||
for file in path.glob('*.toml'):
|
||||
identifier = file.with_suffix('').stem
|
||||
if loader.parse(identifier, file):
|
||||
loader.register(identifier)
|
||||
return loader.configs
|
||||
@@ -183,6 +207,6 @@ def request_config(kind_id: str):
|
||||
"""
|
||||
try:
|
||||
configs = loader(kindmap(kind_id))
|
||||
except KeyError as e:
|
||||
print(f"Unknown Voicemeeter kind '{kind_id}'")
|
||||
except KeyError:
|
||||
raise VBANCMDError(f'Unknown Voicemeeter kind {kind_id}')
|
||||
return configs
|
||||
|
||||
20
vban_cmd/enums.py
Normal file
20
vban_cmd/enums.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from enum import Enum, IntEnum, unique
|
||||
|
||||
|
||||
@unique
|
||||
class KindId(Enum):
|
||||
BASIC = 1
|
||||
BANANA = 2
|
||||
POTATO = 3
|
||||
|
||||
|
||||
class NBS(IntEnum):
|
||||
zero = 0
|
||||
one = 1
|
||||
|
||||
|
||||
BusModes = IntEnum(
|
||||
'BusModes',
|
||||
'normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly',
|
||||
start=0,
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
class VMCMDErrors(Exception):
|
||||
"""general errors"""
|
||||
class VBANCMDError(Exception):
|
||||
"""Base VBANCMD Exception class."""
|
||||
|
||||
pass
|
||||
|
||||
class VBANCMDConnectionError(VBANCMDError):
|
||||
"""Exception raised when connection/timeout errors occur"""
|
||||
|
||||
56
vban_cmd/event.py
Normal file
56
vban_cmd/event.py
Normal 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 += ('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)
|
||||
@@ -1,15 +1,22 @@
|
||||
from abc import abstractmethod
|
||||
import abc
|
||||
import logging
|
||||
from enum import IntEnum
|
||||
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 .command import Command
|
||||
from .config import request_config as configs
|
||||
from .error import VBANCMDError
|
||||
from .kinds import KindMapClass
|
||||
from .kinds import request_kind_map as kindmap
|
||||
from .macrobutton import MacroButton
|
||||
from .recorder import Recorder
|
||||
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:
|
||||
@@ -19,58 +26,80 @@ class FactoryBuilder:
|
||||
Separates construction from representation.
|
||||
"""
|
||||
|
||||
BuilderProgress = IntEnum("BuilderProgress", "strip bus command", start=0)
|
||||
BuilderProgress = IntEnum(
|
||||
'BuilderProgress', 'strip bus command macrobutton vban recorder', start=0
|
||||
)
|
||||
|
||||
def __init__(self, factory, kind: KindMapClass):
|
||||
self._factory = factory
|
||||
self.kind = kind
|
||||
self._info = (
|
||||
f"Finished building strips for {self._factory}",
|
||||
f"Finished building buses for {self._factory}",
|
||||
f"Finished building commands for {self._factory}",
|
||||
f'Finished building strips for {self._factory}',
|
||||
f'Finished building buses 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}',
|
||||
f'Finished building recorder 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"""
|
||||
name = name.split("_")[1]
|
||||
print(self._info[int(getattr(self.BuilderProgress, name))])
|
||||
name = name.split('_')[1]
|
||||
self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
|
||||
|
||||
def make_strip(self) -> Self:
|
||||
def make_strip(self):
|
||||
self._factory.strip = tuple(
|
||||
strip(i < self.kind.phys_in, self._factory, i)
|
||||
for i in range(self.kind.num_strip)
|
||||
)
|
||||
return self
|
||||
|
||||
def make_bus(self) -> Self:
|
||||
def make_bus(self):
|
||||
self._factory.bus = tuple(
|
||||
bus(i < self.kind.phys_out, self._factory, i)
|
||||
for i in range(self.kind.num_bus)
|
||||
)
|
||||
return self
|
||||
|
||||
def make_command(self) -> Self:
|
||||
def make_command(self):
|
||||
self._factory.command = Command.make(self._factory)
|
||||
return self
|
||||
|
||||
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
|
||||
|
||||
def make_recorder(self):
|
||||
self._factory.recorder = Recorder.make(self._factory)
|
||||
return self
|
||||
|
||||
|
||||
class FactoryBase(VbanCmd):
|
||||
"""Base class for factories, subclasses VbanCmd."""
|
||||
|
||||
def __init__(self, kind_id: str, **kwargs):
|
||||
defaultsubs = {"pdirty": True, "ldirty": False}
|
||||
if "subs" in kwargs:
|
||||
defaultsubs = defaultsubs | kwargs.pop("subs")
|
||||
defaultkwargs = {
|
||||
"ip": None,
|
||||
"port": 6980,
|
||||
"streamname": "Command1",
|
||||
"bps": 0,
|
||||
"channel": 0,
|
||||
"ratelimit": 0.01,
|
||||
"sync": False,
|
||||
"subs": defaultsubs,
|
||||
'host': 'localhost',
|
||||
'port': 6980,
|
||||
'streamname': 'Command1',
|
||||
'bps': 256000,
|
||||
'channel': 0,
|
||||
'script_ratelimit': 0.05, # 20 commands per second, to avoid overloading Voicemeeter
|
||||
'timeout': 5, # timeout on socket operations, in seconds
|
||||
'disable_rt_listeners': False,
|
||||
'sync': False,
|
||||
'pdirty': False,
|
||||
'ldirty': False,
|
||||
}
|
||||
if 'ip' in kwargs:
|
||||
defaultkwargs['host'] = kwargs.pop('ip') # for backwards compatibility
|
||||
if 'subs' in kwargs:
|
||||
defaultkwargs |= kwargs.pop('subs') # for backwards compatibility
|
||||
kwargs = defaultkwargs | kwargs
|
||||
self.kind = kindmap(kind_id)
|
||||
super().__init__(**kwargs)
|
||||
@@ -79,14 +108,22 @@ class FactoryBase(VbanCmd):
|
||||
self.builder.make_strip,
|
||||
self.builder.make_bus,
|
||||
self.builder.make_command,
|
||||
self.builder.make_macrobutton,
|
||||
self.builder.make_vban,
|
||||
)
|
||||
self._configs = None
|
||||
|
||||
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
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def steps(self):
|
||||
pass
|
||||
|
||||
@@ -137,7 +174,7 @@ class BananaFactory(FactoryBase):
|
||||
@property
|
||||
def steps(self) -> Iterable:
|
||||
"""steps required to build the interface for a kind"""
|
||||
return self._steps
|
||||
return self._steps + (self.builder.make_recorder,)
|
||||
|
||||
|
||||
class PotatoFactory(FactoryBase):
|
||||
@@ -159,7 +196,7 @@ class PotatoFactory(FactoryBase):
|
||||
@property
|
||||
def steps(self) -> Iterable:
|
||||
"""steps required to build the interface for a kind"""
|
||||
return self._steps
|
||||
return self._steps + (self.builder.make_recorder,)
|
||||
|
||||
|
||||
def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
||||
@@ -169,15 +206,21 @@ def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
||||
Returns a VbanCmd class of a kind
|
||||
"""
|
||||
match kind_id:
|
||||
case "basic":
|
||||
case 'basic':
|
||||
_factory = BasicFactory
|
||||
case "banana":
|
||||
case 'banana':
|
||||
_factory = BananaFactory
|
||||
case "potato":
|
||||
case 'potato' | 'matrix':
|
||||
# matrix is a special kind where:
|
||||
# - we don't need to scale the interface with the builder (in other words kind is arbitrary).
|
||||
# - we don't ever need to use real-time listeners, so we disable them to avoid confusion
|
||||
if kind_id == 'matrix':
|
||||
kwargs['disable_rt_listeners'] = True
|
||||
kind_id = 'potato'
|
||||
_factory = PotatoFactory
|
||||
case _:
|
||||
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
|
||||
return type(f"VbanCmd{kind_id.capitalize()}", (_factory,), {})(kind_id, **kwargs)
|
||||
return type(f'VbanCmd{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs)
|
||||
|
||||
|
||||
def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
||||
@@ -186,9 +229,12 @@ def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
||||
|
||||
Returns a reference to a VbanCmd class of a kind
|
||||
"""
|
||||
logger_entry = logger.getChild('factory.request_vbancmd_obj')
|
||||
|
||||
VBANCMD_obj = None
|
||||
try:
|
||||
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
|
||||
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
|
||||
|
||||
@@ -1,81 +1,10 @@
|
||||
import time
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import abc
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Modes:
|
||||
"""Channel Modes"""
|
||||
|
||||
_mute: hex = 0x00000001
|
||||
_solo: hex = 0x00000002
|
||||
_mono: hex = 0x00000004
|
||||
_mc: hex = 0x00000008
|
||||
|
||||
_amix: hex = 0x00000010
|
||||
_repeat: hex = 0x00000020
|
||||
_bmix: hex = 0x00000030
|
||||
_composite: hex = 0x00000040
|
||||
_tvmix: hex = 0x00000050
|
||||
_upmix21: hex = 0x00000060
|
||||
_upmix41: hex = 0x00000070
|
||||
_upmix61: hex = 0x00000080
|
||||
_centeronly: hex = 0x00000090
|
||||
_lfeonly: hex = 0x000000A0
|
||||
_rearonly: hex = 0x000000B0
|
||||
|
||||
_mask: hex = 0x000000F0
|
||||
|
||||
_eq_on: hex = 0x00000100
|
||||
_cross: hex = 0x00000200
|
||||
_eq_ab: hex = 0x00000800
|
||||
|
||||
_busa: hex = 0x00001000
|
||||
_busa1: hex = 0x00001000
|
||||
_busa2: hex = 0x00002000
|
||||
_busa3: hex = 0x00004000
|
||||
_busa4: hex = 0x00008000
|
||||
_busa5: hex = 0x00080000
|
||||
|
||||
_busb: hex = 0x00010000
|
||||
_busb1: hex = 0x00010000
|
||||
_busb2: hex = 0x00020000
|
||||
_busb3: hex = 0x00040000
|
||||
|
||||
_pan0: hex = 0x00000000
|
||||
_pancolor: hex = 0x00100000
|
||||
_panmod: hex = 0x00200000
|
||||
_panmask: hex = 0x00F00000
|
||||
|
||||
_postfx_r: hex = 0x01000000
|
||||
_postfx_d: hex = 0x02000000
|
||||
_postfx1: hex = 0x04000000
|
||||
_postfx2: hex = 0x08000000
|
||||
|
||||
_sel: hex = 0x10000000
|
||||
_monitor: hex = 0x20000000
|
||||
|
||||
@property
|
||||
def modevals(self):
|
||||
return (
|
||||
val
|
||||
for val in [
|
||||
self._amix,
|
||||
self._repeat,
|
||||
self._bmix,
|
||||
self._composite,
|
||||
self._tvmix,
|
||||
self._upmix21,
|
||||
self._upmix41,
|
||||
self._upmix61,
|
||||
self._centeronly,
|
||||
self._lfeonly,
|
||||
self._rearonly,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class IRemote(metaclass=ABCMeta):
|
||||
class IRemote(abc.ABC):
|
||||
"""
|
||||
Common interface between base class and extended (higher) classes
|
||||
|
||||
@@ -85,46 +14,60 @@ class IRemote(metaclass=ABCMeta):
|
||||
def __init__(self, remote, index=None):
|
||||
self._remote = remote
|
||||
self.index = index
|
||||
self._modes = Modes()
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
|
||||
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:
|
||||
return self._remote.cache.pop(cmd)
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
|
||||
def setter(self, param, val):
|
||||
"""Sends a string request RT packet."""
|
||||
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)
|
||||
|
||||
@abstractmethod
|
||||
def _cmd(self, param):
|
||||
cmd = (self.identifier,)
|
||||
if param:
|
||||
cmd += (f'.{param}',)
|
||||
return ''.join(cmd)
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def identifier(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def public_packet(self):
|
||||
def public_packets(self):
|
||||
"""Returns an RT data packet."""
|
||||
return self._remote.public_packet
|
||||
return self._remote.public_packets
|
||||
|
||||
def apply(self, data):
|
||||
"""Sets all parameters of a dict for the channel."""
|
||||
|
||||
script = ''
|
||||
|
||||
def fget(attr, val):
|
||||
if attr == "mode":
|
||||
return (f"mode.{val}", 1)
|
||||
if attr == 'mode':
|
||||
return (f'mode.{val}', 1)
|
||||
elif attr == 'knob':
|
||||
return ('', val)
|
||||
return (attr, val)
|
||||
|
||||
script = str()
|
||||
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)
|
||||
if isinstance(val, bool):
|
||||
val = 1 if val else 0
|
||||
|
||||
self._remote.cache[f"{self.identifier}[{self.index}].{attr}"] = val
|
||||
script += f"{self.identifier}[{self.index}].{attr}={val};"
|
||||
self._remote.cache[self._cmd(attr)] = val
|
||||
script += f'{self._cmd(attr)}={val};'
|
||||
else:
|
||||
target = getattr(self, attr)
|
||||
target.apply(val)
|
||||
|
||||
self._remote.sendtext(script)
|
||||
return self
|
||||
|
||||
def then_wait(self):
|
||||
time.sleep(self._remote.DELAY)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, unique
|
||||
|
||||
|
||||
@unique
|
||||
class KindId(Enum):
|
||||
BASIC = 1
|
||||
BANANA = 2
|
||||
POTATO = 3
|
||||
from .enums import KindId
|
||||
from .error import VBANCMDError
|
||||
|
||||
|
||||
class SingletonType(type):
|
||||
@@ -20,12 +15,15 @@ class SingletonType(type):
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class KindMapClass(metaclass=SingletonType):
|
||||
name: str
|
||||
ins: tuple
|
||||
outs: tuple
|
||||
vban: tuple
|
||||
strip_channels: int
|
||||
bus_channels: int
|
||||
cells: int
|
||||
|
||||
@property
|
||||
def phys_in(self):
|
||||
@@ -51,44 +49,61 @@ class KindMapClass(metaclass=SingletonType):
|
||||
def num_bus(self):
|
||||
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:
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class BasicMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (2, 1)
|
||||
outs: tuple = (1, 1)
|
||||
vban: tuple = (4, 4)
|
||||
vban: tuple = (4, 4, 1, 1)
|
||||
strip_channels: int = 0
|
||||
bus_channels: int = 0
|
||||
cells: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class BananaMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (3, 2)
|
||||
outs: tuple = (3, 2)
|
||||
vban: tuple = (8, 8)
|
||||
vban: tuple = (8, 8, 1, 1)
|
||||
strip_channels: int = 0
|
||||
bus_channels: int = 8
|
||||
cells: int = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class PotatoMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (5, 3)
|
||||
outs: tuple = (5, 3)
|
||||
vban: tuple = (8, 8)
|
||||
vban: tuple = (8, 8, 1, 1)
|
||||
strip_channels: int = 2
|
||||
bus_channels: int = 8
|
||||
cells: int = 6
|
||||
|
||||
|
||||
def kind_factory(kind_id):
|
||||
match kind_id:
|
||||
case "basic":
|
||||
case 'basic':
|
||||
_kind_map = BasicMap
|
||||
case "banana":
|
||||
case 'banana':
|
||||
_kind_map = BananaMap
|
||||
case "potato":
|
||||
case 'potato':
|
||||
_kind_map = PotatoMap
|
||||
case _:
|
||||
raise ValueError(f"Unknown Voicemeeter kind {kind_id}")
|
||||
raise ValueError(f'Unknown Voicemeeter kind {kind_id}')
|
||||
return _kind_map(name=kind_id)
|
||||
|
||||
|
||||
@@ -97,8 +112,8 @@ def request_kind_map(kind_id):
|
||||
try:
|
||||
KIND_obj = kind_factory(kind_id)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
raise VBANCMDError(str(e)) from e
|
||||
return KIND_obj
|
||||
|
||||
|
||||
kinds_all = list(request_kind_map(kind_id.name.lower()) for kind_id in KindId)
|
||||
all = list(request_kind_map(kind_id.name.lower()) for kind_id in KindId)
|
||||
|
||||
36
vban_cmd/macrobutton.py
Normal file
36
vban_cmd/macrobutton.py
Normal 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')
|
||||
179
vban_cmd/meta.py
179
vban_cmd/meta.py
@@ -1,7 +1,8 @@
|
||||
from functools import partial
|
||||
|
||||
from .error import VMCMDErrors
|
||||
from .util import cache_bool, cache_string
|
||||
from .enums import NBS, BusModes
|
||||
from .packet.enums import ChannelModes
|
||||
from .util import cache_bool, cache_float, cache_int, cache_string
|
||||
|
||||
|
||||
def channel_bool_prop(param):
|
||||
@@ -9,17 +10,25 @@ def channel_bool_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
return (
|
||||
not int.from_bytes(
|
||||
getattr(
|
||||
self.public_packet,
|
||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}state",
|
||||
)[self.index],
|
||||
"little",
|
||||
)
|
||||
& getattr(self._modes, f'_{param.replace(".", "_").lower()}')
|
||||
== 0
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
states = self.public_packets[NBS.zero].states
|
||||
channel_states = (
|
||||
states.strip if 'strip' in type(self).__name__.lower() else states.bus
|
||||
)
|
||||
channel_state = channel_states[self.index]
|
||||
|
||||
if param.lower() == 'mute':
|
||||
return channel_state.mute
|
||||
elif param.lower() == 'solo':
|
||||
return channel_state.solo
|
||||
elif param.lower() == 'mono':
|
||||
return channel_state.mono
|
||||
elif param.lower() == 'mc':
|
||||
return channel_state.mc
|
||||
else:
|
||||
return channel_state.get_mode(getattr(ChannelModes, param.upper()).value)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -27,18 +36,48 @@ def channel_bool_prop(param):
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def channel_int_prop(param):
|
||||
"""meta function for channel integer parameters"""
|
||||
|
||||
@partial(cache_int, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
states = self.public_packets[NBS.zero].states
|
||||
channel_states = (
|
||||
states.strip if 'strip' in type(self).__name__.lower() else states.bus
|
||||
)
|
||||
channel_state = channel_states[self.index]
|
||||
|
||||
# Special case: bus mono is an integer (0-2) encoded using bits 2 and 9
|
||||
if param.lower() == 'mono' and 'bus' in type(self).__name__.lower():
|
||||
bit_2 = (channel_state._state >> 2) & 1
|
||||
bit_9 = (channel_state._state >> 9) & 1
|
||||
return (bit_9 << 1) | bit_2
|
||||
else:
|
||||
return channel_state.get_mode_int(
|
||||
getattr(ChannelModes, param.upper()).value
|
||||
)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def channel_label_prop():
|
||||
"""meta function for channel label parameters"""
|
||||
|
||||
@partial(cache_string, param="label")
|
||||
@partial(cache_string, param='label')
|
||||
def fget(self) -> str:
|
||||
return getattr(
|
||||
self.public_packet,
|
||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}labels",
|
||||
)[self.index]
|
||||
if 'strip' in type(self).__name__.lower():
|
||||
return self.public_packets[NBS.zero].labels.strip[self.index]
|
||||
else:
|
||||
return self.public_packets[NBS.zero].labels.bus[self.index]
|
||||
|
||||
def fset(self, val: str):
|
||||
self.setter("label", str(val))
|
||||
self.setter('label', f'"{val}"')
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
@@ -48,11 +87,12 @@ def strip_output_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
return (
|
||||
not int.from_bytes(self.public_packet.stripstate[self.index], "little")
|
||||
& getattr(self._modes, f"_bus{param.lower()}")
|
||||
== 0
|
||||
)
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
strip_state = self.public_packets[NBS.zero].states.strip[self.index]
|
||||
|
||||
return strip_state.get_mode(getattr(ChannelModes, f'BUS{param.upper()}').value)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -65,26 +105,17 @@ def bus_mode_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
modelist = {
|
||||
"amix": (1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1),
|
||||
"repeat": (0, 2, 2, 0, 0, 2, 2, 0, 0, 2, 2),
|
||||
"bmix": (1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3),
|
||||
"composite": (0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0),
|
||||
"tvmix": (1, 0, 1, 4, 5, 4, 5, 0, 1, 0, 1),
|
||||
"upmix21": (0, 2, 2, 4, 4, 6, 6, 0, 0, 2, 2),
|
||||
"upmix41": (1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3),
|
||||
"upmix61": (0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8),
|
||||
"centeronly": (1, 0, 1, 0, 1, 0, 1, 8, 9, 8, 9),
|
||||
"lfeonly": (0, 2, 2, 0, 0, 2, 2, 8, 8, 10, 10),
|
||||
"rearonly": (1, 2, 3, 0, 1, 2, 3, 8, 9, 10, 11),
|
||||
}
|
||||
vals = (
|
||||
int.from_bytes(self.public_packet.busstate[self.index], "little") & val
|
||||
for val in self._modes.modevals
|
||||
)
|
||||
if param == "normal":
|
||||
return not any(vals)
|
||||
return tuple(round(val / 16) for val in vals) == modelist[param]
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||
|
||||
# Extract current bus mode from bits 4-7
|
||||
current_mode = (bus_state._state & 0x000000F0) >> 4
|
||||
|
||||
expected_mode = getattr(BusModes, param.lower())
|
||||
|
||||
return current_mode == expected_mode
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -92,10 +123,68 @@ def bus_mode_prop(param):
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def action_prop(param, val=1):
|
||||
"""A param that performs an action"""
|
||||
def action_fn(param, val=1):
|
||||
"""A function that performs an action"""
|
||||
|
||||
def fdo(self):
|
||||
self.setter(param, val)
|
||||
|
||||
return fdo
|
||||
|
||||
|
||||
def xy_prop(param):
|
||||
"""meta function for XY pad parameters"""
|
||||
|
||||
@partial(cache_float, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
|
||||
positions = self.public_packets[NBS.one].strips[self.index].positions
|
||||
match param:
|
||||
case 'pan_x':
|
||||
return positions.pan_x
|
||||
case 'pan_y':
|
||||
return positions.pan_y
|
||||
case 'color_x':
|
||||
return positions.color_x
|
||||
case 'color_y':
|
||||
return positions.color_y
|
||||
case 'fx1':
|
||||
return positions.fx1
|
||||
case 'fx2':
|
||||
return positions.fx2
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def send_prop(param):
|
||||
"""meta function for send parameters"""
|
||||
|
||||
@partial(cache_float, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
|
||||
sends = self.public_packets[NBS.one].strips[self.index].sends
|
||||
match param:
|
||||
case 'reverb':
|
||||
return sends.reverb
|
||||
case 'delay':
|
||||
return sends.delay
|
||||
case 'fx1':
|
||||
return sends.fx1
|
||||
case 'fx2':
|
||||
return sends.fx2
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
@@ -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")
|
||||
@@ -1,288 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Generator
|
||||
|
||||
VBAN_SERVICE_RTPACKETREGISTER = 32
|
||||
VBAN_SERVICE_RTPACKET = 33
|
||||
MAX_PACKET_SIZE = 1436
|
||||
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 + 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class VBAN_VMRT_Packet_Data:
|
||||
"""Represents the structure of a VMRT data packet"""
|
||||
|
||||
_voicemeeterType: bytes
|
||||
_reserved: bytes
|
||||
_buffersize: bytes
|
||||
_voicemeeterVersion: bytes
|
||||
_optionBits: bytes
|
||||
_samplerate: bytes
|
||||
_inputLeveldB100: bytes
|
||||
_outputLeveldB100: bytes
|
||||
_TransportBit: bytes
|
||||
_stripState: bytes
|
||||
_busState: bytes
|
||||
_stripGaindB100Layer1: bytes
|
||||
_stripGaindB100Layer2: bytes
|
||||
_stripGaindB100Layer3: bytes
|
||||
_stripGaindB100Layer4: bytes
|
||||
_stripGaindB100Layer5: bytes
|
||||
_stripGaindB100Layer6: bytes
|
||||
_stripGaindB100Layer7: bytes
|
||||
_stripGaindB100Layer8: bytes
|
||||
_busGaindB100: bytes
|
||||
_stripLabelUTF8c60: bytes
|
||||
_busLabelUTF8c60: bytes
|
||||
|
||||
def pdirty(self, other):
|
||||
"""True iff any defined parameter has changed"""
|
||||
|
||||
return not (
|
||||
self._stripState == other._stripState
|
||||
and self._busState == other._busState
|
||||
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
|
||||
and self._busLabelUTF8c60 == other._busLabelUTF8c60
|
||||
and self._stripGaindB100Layer1 == other._stripGaindB100Layer1
|
||||
and self._stripGaindB100Layer2 == other._stripGaindB100Layer2
|
||||
and self._stripGaindB100Layer3 == other._stripGaindB100Layer3
|
||||
and self._stripGaindB100Layer4 == other._stripGaindB100Layer4
|
||||
and self._stripGaindB100Layer5 == other._stripGaindB100Layer5
|
||||
and self._stripGaindB100Layer6 == other._stripGaindB100Layer6
|
||||
and self._stripGaindB100Layer7 == other._stripGaindB100Layer7
|
||||
and self._stripGaindB100Layer8 == other._stripGaindB100Layer8
|
||||
and self._busGaindB100 == other._busGaindB100
|
||||
)
|
||||
|
||||
@property
|
||||
def voicemeetertype(self) -> str:
|
||||
"""returns voicemeeter type as a string"""
|
||||
type_ = ("basic", "banana", "potato")
|
||||
return type_[int.from_bytes(self._voicemeeterType, "little") - 1]
|
||||
|
||||
@property
|
||||
def voicemeeterversion(self) -> tuple:
|
||||
"""returns voicemeeter version as a tuple"""
|
||||
return tuple(
|
||||
reversed(
|
||||
tuple(
|
||||
int.from_bytes(self._voicemeeterVersion[i : i + 1], "little")
|
||||
for i in range(4)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def samplerate(self) -> int:
|
||||
"""returns samplerate as an int"""
|
||||
return int.from_bytes(self._samplerate, "little")
|
||||
|
||||
@property
|
||||
def inputlevels(self) -> Generator[float, None, None]:
|
||||
"""returns the entire level array across all inputs"""
|
||||
for i in range(0, 68, 2):
|
||||
val = ((1 << 16) - 1) - int.from_bytes(
|
||||
self._inputLeveldB100[i : i + 2], "little"
|
||||
)
|
||||
if val != ((1 << 16) - 1):
|
||||
yield val
|
||||
|
||||
@property
|
||||
def outputlevels(self) -> Generator[float, None, None]:
|
||||
"""returns the entire level array across all outputs"""
|
||||
for i in range(0, 128, 2):
|
||||
val = ((1 << 16) - 1) - int.from_bytes(
|
||||
self._outputLeveldB100[i : i + 2], "little"
|
||||
)
|
||||
if val != ((1 << 16) - 1):
|
||||
yield val
|
||||
|
||||
@property
|
||||
def stripstate(self) -> tuple:
|
||||
"""returns tuple of strip states accessable through bit modes"""
|
||||
return tuple(self._stripState[i : i + 4] for i in range(0, 32, 4))
|
||||
|
||||
@property
|
||||
def busstate(self) -> tuple:
|
||||
"""returns tuple of bus states accessable through bit modes"""
|
||||
return tuple(self._busState[i : i + 4] for i in range(0, 32, 4))
|
||||
|
||||
"""
|
||||
these functions return an array of gainlayers[i] across all strips
|
||||
ie stripgainlayer1 = [strip[0].gainlayer[0], strip[1].gainlayer[0], strip[2].gainlayer[0]...]
|
||||
"""
|
||||
|
||||
@property
|
||||
def stripgainlayer1(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer1[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer2(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer2[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer3(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer3[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer4(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer4[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer5(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer5[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer6(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer6[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer7(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer7[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def stripgainlayer8(self) -> tuple:
|
||||
return tuple(
|
||||
((1 << 16) - 1)
|
||||
- int.from_bytes(self._stripGaindB100Layer8[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def busgain(self) -> tuple:
|
||||
"""returns tuple of bus gains"""
|
||||
return tuple(
|
||||
((1 << 16) - 1) - int.from_bytes(self._busGaindB100[i : i + 2], "little")
|
||||
for i in range(0, 16, 2)
|
||||
)
|
||||
|
||||
@property
|
||||
def striplabels(self) -> tuple:
|
||||
"""returns tuple of strip labels"""
|
||||
return tuple(
|
||||
self._stripLabelUTF8c60[i : i + 60].decode().split("\x00")[0]
|
||||
for i in range(0, 480, 60)
|
||||
)
|
||||
|
||||
@property
|
||||
def buslabels(self) -> tuple:
|
||||
"""returns tuple of bus labels"""
|
||||
return tuple(
|
||||
self._busLabelUTF8c60[i : i + 60].decode().split("\x00")[0]
|
||||
for i in range(0, 480, 60)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VBAN_VMRT_Packet_Header:
|
||||
"""Represents a RESPONSE RT PACKET header"""
|
||||
|
||||
name = "Voicemeeter-RTP"
|
||||
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_RTPACKET).to_bytes(1, "little")
|
||||
format_bit: bytes = (0).to_bytes(1, "little")
|
||||
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
|
||||
|
||||
@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
|
||||
assert len(header) == HEADER_SIZE - 4, f"Header expected {HEADER_SIZE-4} bytes"
|
||||
return header
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextRequestHeader:
|
||||
"""Represents a REQUEST RT PACKET header"""
|
||||
|
||||
name: str
|
||||
bps_index: int
|
||||
channel: int
|
||||
vban: bytes = "VBAN".encode()
|
||||
nbs: bytes = (0).to_bytes(1, "little")
|
||||
bit: bytes = (0x10).to_bytes(1, "little")
|
||||
framecounter: bytes = (0).to_bytes(4, "little")
|
||||
|
||||
@property
|
||||
def sr(self):
|
||||
return (0x40 + self.bps_index).to_bytes(1, "little")
|
||||
|
||||
@property
|
||||
def nbc(self):
|
||||
return (self.channel).to_bytes(1, "little")
|
||||
|
||||
@property
|
||||
def streamname(self):
|
||||
return self.name.encode() + bytes(16 - len(self.name))
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
header = self.vban
|
||||
header += self.sr
|
||||
header += self.nbs
|
||||
header += self.nbc
|
||||
header += self.bit
|
||||
header += self.streamname
|
||||
header += self.framecounter
|
||||
assert len(header) == HEADER_SIZE, f"Header expected {HEADER_SIZE} bytes"
|
||||
return header
|
||||
|
||||
|
||||
@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
|
||||
83
vban_cmd/packet/enums.py
Normal file
83
vban_cmd/packet/enums.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from enum import Flag
|
||||
|
||||
|
||||
class SubProtocols(Flag):
|
||||
"""Sub Protocols - Bit flags that can be combined"""
|
||||
|
||||
AUDIO = 0x00
|
||||
SERIAL = 0x20
|
||||
TEXT = 0x40
|
||||
SERVICE = 0x60
|
||||
MASK = 0xE0
|
||||
|
||||
|
||||
class ServiceTypes(Flag):
|
||||
"""Service Types - Bit flags that can be combined"""
|
||||
|
||||
PING = 0
|
||||
PONG = 0
|
||||
CHATUTF8 = 1
|
||||
RTPACKETREGISTER = 32
|
||||
RTPACKET = 33
|
||||
REQUESTREPLY = 0x02 # A Matrix reply
|
||||
FNCT_REPLY = 0x80 # An RTPacket reply
|
||||
|
||||
|
||||
class StreamTypes(Flag):
|
||||
"""Stream Types - Bit flags that can be combined"""
|
||||
|
||||
ASCII = 0x00
|
||||
UTF8 = 0x10
|
||||
WCHAR = 0x20
|
||||
|
||||
|
||||
class ChannelModes(Flag):
|
||||
"""Channel Modes - Bit flags that can be combined"""
|
||||
|
||||
MUTE = 0x00000001
|
||||
SOLO = 0x00000002
|
||||
MONO = 0x00000004
|
||||
MC = 0x00000008
|
||||
|
||||
AMIX = 0x00000010
|
||||
REPEAT = 0x00000020
|
||||
BMIX = 0x00000030
|
||||
COMPOSITE = 0x00000040
|
||||
TVMIX = 0x00000050
|
||||
UPMIX21 = 0x00000060
|
||||
UPMIX41 = 0x00000070
|
||||
UPMIX61 = 0x00000080
|
||||
CENTERONLY = 0x00000090
|
||||
LFEONLY = 0x000000A0
|
||||
REARONLY = 0x000000B0
|
||||
|
||||
MASK = 0x000000F0
|
||||
|
||||
ON = 0x00000100 # eq.on
|
||||
CROSS = 0x00000200
|
||||
AB = 0x00000800 # eq.ab
|
||||
|
||||
BUSA = 0x00001000
|
||||
BUSA1 = 0x00001000
|
||||
BUSA2 = 0x00002000
|
||||
BUSA3 = 0x00004000
|
||||
BUSA4 = 0x00008000
|
||||
BUSA5 = 0x00080000
|
||||
|
||||
BUSB = 0x00010000
|
||||
BUSB1 = 0x00010000
|
||||
BUSB2 = 0x00020000
|
||||
BUSB3 = 0x00040000
|
||||
|
||||
PAN0 = 0x00000000
|
||||
PANCOLOR = 0x00100000
|
||||
PANMOD = 0x00200000
|
||||
PANMASK = 0x00F00000
|
||||
|
||||
POSTFX_R = 0x01000000
|
||||
POSTFX_D = 0x02000000
|
||||
POSTFX1 = 0x04000000
|
||||
POSTFX2 = 0x08000000
|
||||
|
||||
SEL = 0x10000000
|
||||
MONITOR = 0x20000000
|
||||
349
vban_cmd/packet/headers.py
Normal file
349
vban_cmd/packet/headers.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
|
||||
from .enums import ServiceTypes, StreamTypes, SubProtocols
|
||||
|
||||
PINGPONG_PACKET_SIZE = 704 # Size of the PING/PONG header + payload in bytes
|
||||
MAX_PACKET_SIZE = 1436
|
||||
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPingHeader:
|
||||
"""Represents the header of a PING packet"""
|
||||
|
||||
name: str = 'PING0'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.PING.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls, framecounter: int = 0) -> bytes:
|
||||
"""Creates the PING header bytes only."""
|
||||
header = cls(framecounter=framecounter)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.format_sr,
|
||||
header.format_nbs,
|
||||
header.format_nbc,
|
||||
header.format_bit,
|
||||
header.streamname,
|
||||
header.framecounter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPongHeader:
|
||||
"""Represents the header of a PONG response packet"""
|
||||
|
||||
name: str = 'PING0'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.PONG.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a PONG response packet from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# PONG responses use the same service type as PING (0x00)
|
||||
# and are identified by having payload data
|
||||
if parsed['format_nbc'] != ServiceTypes.PONG.value:
|
||||
raise ValueError(f'Not a PONG response packet: {parsed["format_nbc"]:02x}')
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
@classmethod
|
||||
def is_pong_response(cls, data: bytes) -> bool:
|
||||
"""Check if packet is a PONG response by analyzing the actual response format."""
|
||||
try:
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is a service protocol packet with PING/PONG service type
|
||||
if parsed['format_nbc'] != ServiceTypes.PONG.value:
|
||||
return False
|
||||
|
||||
if parsed['name'] not in ['PING0', 'VBAN Service']:
|
||||
return False
|
||||
|
||||
# PONG should have payload data (same size as PING)
|
||||
return len(data) >= PINGPONG_PACKET_SIZE
|
||||
|
||||
except (ValueError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacket:
|
||||
"""Represents the header of an incoming RTPacket"""
|
||||
|
||||
nbs: NBS
|
||||
_kind: KindMapClass
|
||||
_voicemeeterType: bytes
|
||||
_reserved: bytes
|
||||
_buffersize: bytes
|
||||
_voicemeeterVersion: bytes
|
||||
_optionBits: bytes
|
||||
_samplerate: bytes
|
||||
|
||||
@property
|
||||
def voicemeetertype(self) -> str:
|
||||
"""returns voicemeeter type as a string"""
|
||||
return ['', 'basic', 'banana', 'potato'][
|
||||
int.from_bytes(self._voicemeeterType, 'little')
|
||||
]
|
||||
|
||||
@property
|
||||
def voicemeeterversion(self) -> tuple:
|
||||
"""returns voicemeeter version as a tuple"""
|
||||
return tuple(self._voicemeeterVersion[i] for i in range(3, -1, -1))
|
||||
|
||||
@property
|
||||
def samplerate(self) -> int:
|
||||
"""returns samplerate as an int"""
|
||||
return int.from_bytes(self._samplerate, 'little')
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTSubscribeHeader:
|
||||
"""Represents the header of an RT subscription packet"""
|
||||
|
||||
nbs: NBS = NBS.zero
|
||||
name: str = 'Register-RTP'
|
||||
timeout: int = 15
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def format_sr(self) -> bytes:
|
||||
return SubProtocols.SERVICE.value.to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_nbs(self) -> bytes:
|
||||
return (self.nbs.value & 0xFF).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_nbc(self) -> bytes:
|
||||
return ServiceTypes.RTPACKETREGISTER.value.to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_bit(self) -> bytes:
|
||||
return (self.timeout & 0xFF).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii') + bytes(16 - len(self.name))
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls, nbs: NBS, framecounter: int) -> bytes:
|
||||
header = cls(nbs=nbs)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.format_sr[0],
|
||||
header.format_nbs[0],
|
||||
header.format_nbc[0],
|
||||
header.format_bit[0],
|
||||
header.streamname,
|
||||
framecounter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTRequestHeader:
|
||||
"""Represents the header of an RT request packet"""
|
||||
|
||||
name: str
|
||||
bps_index: int
|
||||
channel: int
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def sr(self) -> bytes:
|
||||
return (self.bps_index | SubProtocols.TEXT.value).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def nbs(self) -> bytes:
|
||||
return (0).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def nbc(self) -> bytes:
|
||||
return (self.channel).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def bit(self) -> bytes:
|
||||
return (StreamTypes.UTF8.value).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode()[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(
|
||||
cls, name: str, bps_index: int, channel: int, framecounter: int
|
||||
) -> bytes:
|
||||
header = cls(
|
||||
name=name, bps_index=bps_index, channel=channel, framecounter=framecounter
|
||||
)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.sr[0],
|
||||
header.nbs[0],
|
||||
header.nbc[0],
|
||||
header.bit[0],
|
||||
header.streamname,
|
||||
header.framecounter,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def encode_with_payload(
|
||||
cls, name: str, bps_index: int, channel: int, framecounter: int, payload: str
|
||||
) -> bytes:
|
||||
"""Creates the complete packet with header and payload."""
|
||||
return cls.to_bytes(name, bps_index, channel, framecounter) + payload.encode()
|
||||
|
||||
|
||||
def _parse_vban_service_header(data: bytes) -> dict:
|
||||
"""Common parsing and validation for VBAN service protocol headers."""
|
||||
if len(data) < HEADER_SIZE:
|
||||
raise ValueError('Data is too short to be a valid VBAN header')
|
||||
|
||||
if data[:4] != b'VBAN':
|
||||
raise ValueError('Invalid VBAN magic bytes')
|
||||
|
||||
format_sr = data[4]
|
||||
format_nbs = data[5]
|
||||
format_nbc = data[6]
|
||||
format_bit = data[7]
|
||||
|
||||
# Verify this is a service protocol packet
|
||||
protocol = format_sr & SubProtocols.MASK.value
|
||||
if protocol != SubProtocols.SERVICE.value:
|
||||
raise ValueError(f'Not a service protocol packet: {protocol:02x}')
|
||||
|
||||
# Extract stream name and frame counter
|
||||
name = data[8:24].rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
framecounter = int.from_bytes(data[24:28], 'little')
|
||||
|
||||
return {
|
||||
'format_sr': format_sr,
|
||||
'format_nbs': format_nbs,
|
||||
'format_nbc': format_nbc,
|
||||
'format_bit': format_bit,
|
||||
'name': name,
|
||||
'framecounter': framecounter,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTResponseHeader:
|
||||
"""Represents the header of an RT response packet"""
|
||||
|
||||
name: str = 'Voicemeeter-RTP'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.RTPACKET.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii') + bytes(16 - len(self.name))
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a VbanResponseHeader from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is an RTPacket response
|
||||
if parsed['format_nbc'] != ServiceTypes.RTPACKET.value:
|
||||
raise ValueError(
|
||||
f'Not an RTPacket response packet: {parsed["format_nbc"]:02x}'
|
||||
)
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanMatrixResponseHeader:
|
||||
"""Represents the header of a matrix response packet"""
|
||||
|
||||
name: str = 'Request Reply'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = ServiceTypes.FNCT_REPLY.value
|
||||
format_nbc: int = ServiceTypes.REQUESTREPLY.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a matrix response packet from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is a service reply packet
|
||||
if parsed['format_nbs'] != ServiceTypes.FNCT_REPLY.value:
|
||||
raise ValueError(f'Not a service reply packet: {parsed["format_nbs"]:02x}')
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
@classmethod
|
||||
def extract_payload(cls, data: bytes) -> str:
|
||||
"""Extract the text payload from a matrix response packet."""
|
||||
if len(data) <= HEADER_SIZE:
|
||||
return ''
|
||||
|
||||
payload_bytes = data[HEADER_SIZE:]
|
||||
return payload_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
|
||||
@classmethod
|
||||
def parse_response(cls, data: bytes) -> tuple['VbanMatrixResponseHeader', str]:
|
||||
"""Parse a complete matrix response packet returning header and payload."""
|
||||
header = cls.from_bytes(data)
|
||||
payload = cls.extract_payload(data)
|
||||
return header, payload
|
||||
260
vban_cmd/packet/nbs0.py
Normal file
260
vban_cmd/packet/nbs0.py
Normal file
@@ -0,0 +1,260 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import NamedTuple
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
from vban_cmd.util import comp
|
||||
|
||||
from .enums import ChannelModes
|
||||
from .headers import VbanRTPacket
|
||||
|
||||
|
||||
class Levels(NamedTuple):
|
||||
strip: tuple[float, ...]
|
||||
bus: tuple[float, ...]
|
||||
|
||||
|
||||
class ChannelState:
|
||||
"""Represents the processed state of a single strip or bus channel"""
|
||||
|
||||
def __init__(self, state_bytes: bytes):
|
||||
# Convert 4-byte state to integer once for efficient lookups
|
||||
self._state = int.from_bytes(state_bytes, 'little')
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, state_int: int):
|
||||
"""Create ChannelState directly from integer for efficiency"""
|
||||
instance = cls.__new__(cls)
|
||||
instance._state = state_int
|
||||
return instance
|
||||
|
||||
def get_mode(self, mode_value: int) -> bool:
|
||||
"""Get boolean state for a specific mode"""
|
||||
return (self._state & mode_value) != 0
|
||||
|
||||
def get_mode_int(self, mode_value: int) -> int:
|
||||
"""Get integer state for a specific mode"""
|
||||
return self._state & mode_value
|
||||
|
||||
# Common boolean modes
|
||||
@property
|
||||
def mute(self) -> bool:
|
||||
return (self._state & ChannelModes.MUTE.value) != 0
|
||||
|
||||
@property
|
||||
def solo(self) -> bool:
|
||||
return (self._state & ChannelModes.SOLO.value) != 0
|
||||
|
||||
@property
|
||||
def mono(self) -> bool:
|
||||
return (self._state & ChannelModes.MONO.value) != 0
|
||||
|
||||
@property
|
||||
def mc(self) -> bool:
|
||||
return (self._state & ChannelModes.MC.value) != 0
|
||||
|
||||
# EQ modes
|
||||
@property
|
||||
def eq_on(self) -> bool:
|
||||
return (self._state & ChannelModes.ON.value) != 0
|
||||
|
||||
@property
|
||||
def eq_ab(self) -> bool:
|
||||
return (self._state & ChannelModes.AB.value) != 0
|
||||
|
||||
# Bus assignments (strip to bus routing)
|
||||
@property
|
||||
def busa1(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA1.value) != 0
|
||||
|
||||
@property
|
||||
def busa2(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA2.value) != 0
|
||||
|
||||
@property
|
||||
def busa3(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA3.value) != 0
|
||||
|
||||
@property
|
||||
def busa4(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA4.value) != 0
|
||||
|
||||
@property
|
||||
def busb1(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB1.value) != 0
|
||||
|
||||
@property
|
||||
def busb2(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB2.value) != 0
|
||||
|
||||
@property
|
||||
def busb3(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB3.value) != 0
|
||||
|
||||
|
||||
class States(NamedTuple):
|
||||
strip: tuple[ChannelState, ...]
|
||||
bus: tuple[ChannelState, ...]
|
||||
|
||||
|
||||
class Labels(NamedTuple):
|
||||
strip: tuple[str, ...]
|
||||
bus: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacketNBS0(VbanRTPacket):
|
||||
"""Represents the body of a VBAN RTPacket with ident:0"""
|
||||
|
||||
_inputLeveldB100: bytes
|
||||
_outputLeveldB100: bytes
|
||||
_TransportBit: bytes
|
||||
_stripState: bytes
|
||||
_busState: bytes
|
||||
_stripGaindB100Layer1: bytes
|
||||
_stripGaindB100Layer2: bytes
|
||||
_stripGaindB100Layer3: bytes
|
||||
_stripGaindB100Layer4: bytes
|
||||
_stripGaindB100Layer5: bytes
|
||||
_stripGaindB100Layer6: bytes
|
||||
_stripGaindB100Layer7: bytes
|
||||
_stripGaindB100Layer8: bytes
|
||||
_busGaindB100: bytes
|
||||
_stripLabelUTF8c60: bytes
|
||||
_busLabelUTF8c60: bytes
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, nbs: NBS, kind: KindMapClass, data: bytes):
|
||||
return cls(
|
||||
nbs=nbs,
|
||||
_kind=kind,
|
||||
_voicemeeterType=data[28:29],
|
||||
_reserved=data[29:30],
|
||||
_buffersize=data[30:32],
|
||||
_voicemeeterVersion=data[32:36],
|
||||
_optionBits=data[36:40],
|
||||
_samplerate=data[40:44],
|
||||
_inputLeveldB100=data[44:112],
|
||||
_outputLeveldB100=data[112:240],
|
||||
_TransportBit=data[240:244],
|
||||
_stripState=data[244:276],
|
||||
_busState=data[276:308],
|
||||
_stripGaindB100Layer1=data[308:324],
|
||||
_stripGaindB100Layer2=data[324:340],
|
||||
_stripGaindB100Layer3=data[340:356],
|
||||
_stripGaindB100Layer4=data[356:372],
|
||||
_stripGaindB100Layer5=data[372:388],
|
||||
_stripGaindB100Layer6=data[388:404],
|
||||
_stripGaindB100Layer7=data[404:420],
|
||||
_stripGaindB100Layer8=data[420:436],
|
||||
_busGaindB100=data[436:452],
|
||||
_stripLabelUTF8c60=data[452:932],
|
||||
_busLabelUTF8c60=data[932:1412],
|
||||
)
|
||||
|
||||
def pdirty(self, other) -> bool:
|
||||
"""True iff any defined parameter has changed"""
|
||||
return (
|
||||
self._stripState != other._stripState
|
||||
or self._busState != other._busState
|
||||
or self._stripGaindB100Layer1 != other._stripGaindB100Layer1
|
||||
or self._stripGaindB100Layer2 != other._stripGaindB100Layer2
|
||||
or self._stripGaindB100Layer3 != other._stripGaindB100Layer3
|
||||
or self._stripGaindB100Layer4 != other._stripGaindB100Layer4
|
||||
or self._stripGaindB100Layer5 != other._stripGaindB100Layer5
|
||||
or self._stripGaindB100Layer6 != other._stripGaindB100Layer6
|
||||
or self._stripGaindB100Layer7 != other._stripGaindB100Layer7
|
||||
or self._stripGaindB100Layer8 != other._stripGaindB100Layer8
|
||||
or self._busGaindB100 != other._busGaindB100
|
||||
or self._stripLabelUTF8c60 != other._stripLabelUTF8c60
|
||||
or self._busLabelUTF8c60 != other._busLabelUTF8c60
|
||||
)
|
||||
|
||||
def ldirty(self, strip_cache, bus_cache) -> bool:
|
||||
"""True iff any level has changed, ignoring changes when levels are very quiet"""
|
||||
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(self._strip_comp) or any(self._bus_comp)
|
||||
|
||||
@cached_property
|
||||
def strip_levels(self) -> tuple[float, ...]:
|
||||
"""Returns strip levels in dB"""
|
||||
strip_raw = struct.unpack('<34h', self._inputLeveldB100)
|
||||
return tuple(round(val * 0.01, 1) for val in strip_raw)[
|
||||
: self._kind.num_strip_levels
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def bus_levels(self) -> tuple[float, ...]:
|
||||
"""Returns bus levels in dB"""
|
||||
bus_raw = struct.unpack('<64h', self._outputLeveldB100)
|
||||
return tuple(round(val * 0.01, 1) for val in bus_raw)[
|
||||
: self._kind.num_bus_levels
|
||||
]
|
||||
|
||||
@property
|
||||
def levels(self) -> Levels:
|
||||
"""Returns strip and bus levels as a namedtuple"""
|
||||
return Levels(strip=self.strip_levels, bus=self.bus_levels)
|
||||
|
||||
@cached_property
|
||||
def states(self) -> States:
|
||||
"""returns States object with processed strip and bus channel states"""
|
||||
strip_states = struct.unpack('<8I', self._stripState)
|
||||
bus_states = struct.unpack('<8I', self._busState)
|
||||
return States(
|
||||
strip=tuple(ChannelState.from_int(state) for state in strip_states),
|
||||
bus=tuple(ChannelState.from_int(state) for state in bus_states),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def gainlayers(self) -> tuple:
|
||||
"""returns tuple of all strip gain layers as tuples"""
|
||||
layer_data = []
|
||||
for layer in range(1, 9):
|
||||
layer_bytes = getattr(self, f'_stripGaindB100Layer{layer}')
|
||||
layer_raw = struct.unpack('<8h', layer_bytes)
|
||||
layer_data.append(tuple(round(val * 0.01, 2) for val in layer_raw))
|
||||
return tuple(layer_data)
|
||||
|
||||
@cached_property
|
||||
def busgain(self) -> tuple:
|
||||
"""returns tuple of bus gains"""
|
||||
bus_gain_raw = struct.unpack('<8h', self._busGaindB100)
|
||||
return tuple(round(val * 0.01, 2) for val in bus_gain_raw)
|
||||
|
||||
@cached_property
|
||||
def labels(self) -> Labels:
|
||||
"""returns Labels namedtuple of strip and bus labels"""
|
||||
|
||||
def _extract_labels_from_bytes(label_bytes: bytes) -> tuple[str, ...]:
|
||||
"""Extract null-terminated UTF-8 labels from 60-byte chunks"""
|
||||
labels = []
|
||||
for i in range(0, len(label_bytes), 60):
|
||||
chunk = label_bytes[i : i + 60]
|
||||
null_pos = chunk.find(b'\x00')
|
||||
if null_pos == -1:
|
||||
try:
|
||||
label = chunk.decode('utf-8', errors='replace').rstrip('\x00')
|
||||
except UnicodeDecodeError:
|
||||
label = ''
|
||||
else:
|
||||
try:
|
||||
label = (
|
||||
chunk[:null_pos].decode('utf-8', errors='replace')
|
||||
if null_pos > 0
|
||||
else ''
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
label = ''
|
||||
labels.append(label)
|
||||
return tuple(labels)
|
||||
|
||||
return Labels(
|
||||
strip=_extract_labels_from_bytes(self._stripLabelUTF8c60),
|
||||
bus=_extract_labels_from_bytes(self._busLabelUTF8c60),
|
||||
)
|
||||
358
vban_cmd/packet/nbs1.py
Normal file
358
vban_cmd/packet/nbs1.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import NamedTuple
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
|
||||
from .headers import VbanRTPacket
|
||||
|
||||
VMPARAMSTRIP_SIZE = 174
|
||||
|
||||
|
||||
class Audibility(NamedTuple):
|
||||
knob: float
|
||||
comp: float
|
||||
gate: float
|
||||
denoiser: float
|
||||
|
||||
|
||||
class Positions(NamedTuple):
|
||||
pan_x: float
|
||||
pan_y: float
|
||||
color_x: float
|
||||
color_y: float
|
||||
fx1: float
|
||||
fx2: float
|
||||
|
||||
|
||||
class EqGains(NamedTuple):
|
||||
bass: float
|
||||
mid: float
|
||||
treble: float
|
||||
|
||||
|
||||
class ParametricEQSettings(NamedTuple):
|
||||
on: bool
|
||||
type: int
|
||||
gain: float
|
||||
freq: float
|
||||
q: float
|
||||
|
||||
|
||||
class Sends(NamedTuple):
|
||||
reverb: float
|
||||
delay: float
|
||||
fx1: float
|
||||
fx2: float
|
||||
|
||||
|
||||
class CompressorSettings(NamedTuple):
|
||||
gain_in: float
|
||||
attack_ms: float
|
||||
release_ms: float
|
||||
n_knee: float
|
||||
ratio: float
|
||||
threshold: float
|
||||
c_enabled: bool
|
||||
makeup: bool
|
||||
gain_out: float
|
||||
|
||||
|
||||
class GateSettings(NamedTuple):
|
||||
threshold_in: float
|
||||
damping_max: float
|
||||
bp_sidechain: bool
|
||||
attack_ms: float
|
||||
hold_ms: float
|
||||
release_ms: float
|
||||
|
||||
|
||||
class DenoiserSettings(NamedTuple):
|
||||
threshold: float
|
||||
|
||||
|
||||
class PitchSettings(NamedTuple):
|
||||
enabled: bool
|
||||
dry_wet: float
|
||||
value: float
|
||||
formant_lo: float
|
||||
formant_med: float
|
||||
formant_high: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanVMParamStrip:
|
||||
"""Represents the VBAN_VMPARAMSTRIP_PACKET structure"""
|
||||
|
||||
_mode: bytes
|
||||
_dblevel: bytes
|
||||
_audibility: bytes
|
||||
_pos3D_x: bytes
|
||||
_pos3D_y: bytes
|
||||
_posColor_x: bytes
|
||||
_posColor_y: bytes
|
||||
_EQgain1: bytes
|
||||
_EQgain2: bytes
|
||||
_EQgain3: bytes
|
||||
|
||||
# First channel parametric EQ
|
||||
_PEQ_eqOn: bytes
|
||||
_PEQ_eqtype: bytes
|
||||
_PEQ_eqgain: bytes
|
||||
_PEQ_eqfreq: bytes
|
||||
_PEQ_eqq: bytes
|
||||
|
||||
_audibility_c: bytes
|
||||
_audibility_g: bytes
|
||||
_audibility_d: bytes
|
||||
_posMod_x: bytes
|
||||
_posMod_y: bytes
|
||||
_send_reverb: bytes
|
||||
_send_delay: bytes
|
||||
_send_fx1: bytes
|
||||
_send_fx2: bytes
|
||||
_dblimit: bytes
|
||||
_nKaraoke: bytes
|
||||
|
||||
_COMP_gain_in: bytes
|
||||
_COMP_attack_ms: bytes
|
||||
_COMP_release_ms: bytes
|
||||
_COMP_n_knee: bytes
|
||||
_COMP_comprate: bytes
|
||||
_COMP_threshold: bytes
|
||||
_COMP_c_enabled: bytes
|
||||
_COMP_c_auto: bytes
|
||||
_COMP_gain_out: bytes
|
||||
|
||||
_GATE_dBThreshold_in: bytes
|
||||
_GATE_dBDamping_max: bytes
|
||||
_GATE_BP_Sidechain: bytes
|
||||
_GATE_attack_ms: bytes
|
||||
_GATE_hold_ms: bytes
|
||||
_GATE_release_ms: bytes
|
||||
|
||||
_DenoiserThreshold: bytes
|
||||
_PitchEnabled: bytes
|
||||
_Pitch_DryWet: bytes
|
||||
_Pitch_Value: bytes
|
||||
_Pitch_formant_lo: bytes
|
||||
_Pitch_formant_med: bytes
|
||||
_Pitch_formant_high: bytes
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
return cls(
|
||||
_mode=data[0:4],
|
||||
_dblevel=data[4:8],
|
||||
_audibility=data[8:10],
|
||||
_pos3D_x=data[10:12],
|
||||
_pos3D_y=data[12:14],
|
||||
_posColor_x=data[14:16],
|
||||
_posColor_y=data[16:18],
|
||||
_EQgain1=data[18:20],
|
||||
_EQgain2=data[20:22],
|
||||
_EQgain3=data[22:24],
|
||||
_PEQ_eqOn=data[24:30],
|
||||
_PEQ_eqtype=data[30:36],
|
||||
_PEQ_eqgain=data[36:60],
|
||||
_PEQ_eqfreq=data[60:84],
|
||||
_PEQ_eqq=data[84:108],
|
||||
_audibility_c=data[108:110],
|
||||
_audibility_g=data[110:112],
|
||||
_audibility_d=data[112:114],
|
||||
_posMod_x=data[114:116],
|
||||
_posMod_y=data[116:118],
|
||||
_send_reverb=data[118:120],
|
||||
_send_delay=data[120:122],
|
||||
_send_fx1=data[122:124],
|
||||
_send_fx2=data[124:126],
|
||||
_dblimit=data[126:128],
|
||||
_nKaraoke=data[128:130],
|
||||
_COMP_gain_in=data[130:132],
|
||||
_COMP_attack_ms=data[132:134],
|
||||
_COMP_release_ms=data[134:136],
|
||||
_COMP_n_knee=data[136:138],
|
||||
_COMP_comprate=data[138:140],
|
||||
_COMP_threshold=data[140:142],
|
||||
_COMP_c_enabled=data[142:144],
|
||||
_COMP_c_auto=data[144:146],
|
||||
_COMP_gain_out=data[146:148],
|
||||
_GATE_dBThreshold_in=data[148:150],
|
||||
_GATE_dBDamping_max=data[150:152],
|
||||
_GATE_BP_Sidechain=data[152:154],
|
||||
_GATE_attack_ms=data[154:156],
|
||||
_GATE_hold_ms=data[156:158],
|
||||
_GATE_release_ms=data[158:160],
|
||||
_DenoiserThreshold=data[160:162],
|
||||
_PitchEnabled=data[162:164],
|
||||
_Pitch_DryWet=data[164:166],
|
||||
_Pitch_Value=data[166:168],
|
||||
_Pitch_formant_lo=data[168:170],
|
||||
_Pitch_formant_med=data[170:172],
|
||||
_Pitch_formant_high=data[172:174],
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def mode(self) -> int:
|
||||
return int.from_bytes(self._mode, 'little')
|
||||
|
||||
@cached_property
|
||||
def karaoke(self) -> int:
|
||||
return int.from_bytes(self._nKaraoke, 'little')
|
||||
|
||||
@cached_property
|
||||
def audibility(self) -> Audibility:
|
||||
return Audibility(
|
||||
round(int.from_bytes(self._audibility, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_c, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_g, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_d, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def positions(self) -> Positions:
|
||||
return Positions(
|
||||
round(int.from_bytes(self._pos3D_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._pos3D_y, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posColor_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posColor_y, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posMod_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posMod_y, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def eqgains(self) -> EqGains:
|
||||
return EqGains(
|
||||
*[
|
||||
round(
|
||||
int.from_bytes(getattr(self, f'_EQgain{i}'), 'little', signed=True)
|
||||
* 0.01,
|
||||
2,
|
||||
)
|
||||
for i in range(1, 4)
|
||||
]
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def parametric_eq(self) -> tuple[ParametricEQSettings, ...]:
|
||||
return tuple(
|
||||
ParametricEQSettings(
|
||||
on=bool(int.from_bytes(self._PEQ_eqOn[i : i + 1], 'little')),
|
||||
type=int.from_bytes(self._PEQ_eqtype[i : i + 1], 'little'),
|
||||
freq=struct.unpack('<f', self._PEQ_eqfreq[i * 4 : (i + 1) * 4])[0],
|
||||
gain=struct.unpack('<f', self._PEQ_eqgain[i * 4 : (i + 1) * 4])[0],
|
||||
q=struct.unpack('<f', self._PEQ_eqq[i * 4 : (i + 1) * 4])[0],
|
||||
)
|
||||
for i in range(6)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def sends(self) -> Sends:
|
||||
return Sends(
|
||||
round(int.from_bytes(self._send_reverb, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_delay, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_fx1, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_fx2, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def compressor(self) -> CompressorSettings:
|
||||
return CompressorSettings(
|
||||
gain_in=round(
|
||||
int.from_bytes(self._COMP_gain_in, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
attack_ms=round(int.from_bytes(self._COMP_attack_ms, 'little') * 0.1, 2),
|
||||
release_ms=round(int.from_bytes(self._COMP_release_ms, 'little') * 0.1, 2),
|
||||
n_knee=round(int.from_bytes(self._COMP_n_knee, 'little') * 0.01, 2),
|
||||
ratio=round(int.from_bytes(self._COMP_comprate, 'little') * 0.01, 2),
|
||||
threshold=round(
|
||||
int.from_bytes(self._COMP_threshold, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
c_enabled=bool(int.from_bytes(self._COMP_c_enabled, 'little')),
|
||||
makeup=bool(int.from_bytes(self._COMP_c_auto, 'little')),
|
||||
gain_out=round(
|
||||
int.from_bytes(self._COMP_gain_out, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def gate(self) -> GateSettings:
|
||||
return GateSettings(
|
||||
threshold_in=round(
|
||||
int.from_bytes(self._GATE_dBThreshold_in, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
damping_max=round(
|
||||
int.from_bytes(self._GATE_dBDamping_max, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
bp_sidechain=round(
|
||||
int.from_bytes(self._GATE_BP_Sidechain, 'little') * 0.1, 2
|
||||
),
|
||||
attack_ms=round(int.from_bytes(self._GATE_attack_ms, 'little') * 0.1, 2),
|
||||
hold_ms=round(int.from_bytes(self._GATE_hold_ms, 'little') * 0.1, 2),
|
||||
release_ms=round(int.from_bytes(self._GATE_release_ms, 'little') * 0.1, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def denoiser(self) -> DenoiserSettings:
|
||||
return DenoiserSettings(
|
||||
threshold=round(
|
||||
int.from_bytes(self._DenoiserThreshold, 'little', signed=True) * 0.01, 2
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def pitch(self) -> PitchSettings:
|
||||
return PitchSettings(
|
||||
enabled=bool(int.from_bytes(self._PitchEnabled, 'little')),
|
||||
dry_wet=round(
|
||||
int.from_bytes(self._Pitch_DryWet, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
value=round(
|
||||
int.from_bytes(self._Pitch_Value, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_lo=round(
|
||||
int.from_bytes(self._Pitch_formant_lo, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_med=round(
|
||||
int.from_bytes(self._Pitch_formant_med, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_high=round(
|
||||
int.from_bytes(self._Pitch_formant_high, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacketNBS1(VbanRTPacket):
|
||||
"""Represents the body of a VBAN RTPacket with ident:1"""
|
||||
|
||||
strips: tuple[VbanVMParamStrip, ...]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(
|
||||
cls,
|
||||
nbs: NBS,
|
||||
kind: KindMapClass,
|
||||
data: bytes,
|
||||
):
|
||||
return cls(
|
||||
nbs=nbs,
|
||||
_kind=kind,
|
||||
_voicemeeterType=data[28:29],
|
||||
_reserved=data[29:30],
|
||||
_buffersize=data[30:32],
|
||||
_voicemeeterVersion=data[32:36],
|
||||
_optionBits=data[36:40],
|
||||
_samplerate=data[40:44],
|
||||
strips=tuple(
|
||||
VbanVMParamStrip.from_bytes(
|
||||
data[44 + i * VMPARAMSTRIP_SIZE : 44 + (i + 1) * VMPARAMSTRIP_SIZE]
|
||||
)
|
||||
for i in range(16)
|
||||
),
|
||||
)
|
||||
126
vban_cmd/packet/ping0.py
Normal file
126
vban_cmd/packet/ping0.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from .headers import VbanPingHeader
|
||||
|
||||
# VBAN PING bitType constants
|
||||
VBANPING_TYPE_RECEPTOR = 0x00000001 # Simple receptor
|
||||
VBANPING_TYPE_TRANSMITTER = 0x00000002 # Simple Transmitter
|
||||
VBANPING_TYPE_RECEPTORSPOT = 0x00000004 # SPOT receptor
|
||||
VBANPING_TYPE_TRANSMITTERSPOT = 0x00000008 # SPOT transmitter
|
||||
VBANPING_TYPE_VIRTUALDEVICE = 0x00000010 # Virtual Device
|
||||
VBANPING_TYPE_VIRTUALMIXER = 0x00000020 # Virtual Mixer
|
||||
VBANPING_TYPE_MATRIX = 0x00000040 # MATRIX
|
||||
VBANPING_TYPE_DAW = 0x00000080 # Workstation
|
||||
VBANPING_TYPE_SERVER = 0x01000000 # VBAN SERVER
|
||||
|
||||
# VBAN PING bitfeature constants
|
||||
VBANPING_FEATURE_AUDIO = 0x00000001
|
||||
VBANPING_FEATURE_AOIP = 0x00000002
|
||||
VBANPING_FEATURE_VOIP = 0x00000004
|
||||
VBANPING_FEATURE_SERIAL = 0x00000100
|
||||
VBANPING_FEATURE_MIDI = 0x00000300
|
||||
VBANPING_FEATURE_FRAME = 0x00001000
|
||||
VBANPING_FEATURE_TXT = 0x00010000
|
||||
|
||||
|
||||
class VbanServerType(Enum):
|
||||
"""VBAN server types detected from PONG responses"""
|
||||
|
||||
UNKNOWN = 0
|
||||
VOICEMEETER = VBANPING_TYPE_VIRTUALMIXER
|
||||
MATRIX = VBANPING_TYPE_MATRIX
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPing0Payload:
|
||||
"""Represents the VBAN PING0 payload structure as defined in the VBAN protocol documentation."""
|
||||
|
||||
def __init__(self):
|
||||
self.bit_type = VBANPING_TYPE_RECEPTOR
|
||||
self.bit_feature = VBANPING_FEATURE_TXT
|
||||
self.bit_feature_ex = 0x00000000
|
||||
self.preferred_rate = 48000
|
||||
self.min_rate = 8000
|
||||
self.max_rate = 192000
|
||||
self.color_rgb = 0x00FF0000
|
||||
self.version = b'\x01\x02\x03\x04'
|
||||
self.gps_position = b'\x00' * 8
|
||||
self.user_position = b'\x00' * 8
|
||||
self.lang_code = b'EN\x00\x00\x00\x00\x00\x00'
|
||||
self.reserved = b'\x00' * 8
|
||||
self.reserved_ex = b'\x00' * 64
|
||||
self.distant_ip = b'\x00' * 32
|
||||
self.distant_port = 0
|
||||
self.distant_reserved = 0
|
||||
self.device_name = b'VBAN-CMD-Python\x00'.ljust(64, b'\x00')
|
||||
self.manufacturer_name = b'Python-VBAN\x00'.ljust(64, b'\x00')
|
||||
self.application_name = b'vban-cmd\x00'.ljust(64, b'\x00')
|
||||
self.host_name = b'localhost\x00'.ljust(64, b'\x00')
|
||||
self.user_name = b'Python User\x00'.ljust(128, b'\x00')
|
||||
self.user_comment = b'VBAN CMD Python Client\x00'.ljust(128, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls) -> bytes:
|
||||
"""Convert payload to bytes"""
|
||||
payload = cls()
|
||||
|
||||
return struct.pack(
|
||||
'<7I4s8s8s8s8s64s32s2H64s64s64s64s128s128s',
|
||||
payload.bit_type,
|
||||
payload.bit_feature,
|
||||
payload.bit_feature_ex,
|
||||
payload.preferred_rate,
|
||||
payload.min_rate,
|
||||
payload.max_rate,
|
||||
payload.color_rgb,
|
||||
payload.version,
|
||||
payload.gps_position,
|
||||
payload.user_position,
|
||||
payload.lang_code,
|
||||
payload.reserved,
|
||||
payload.reserved_ex,
|
||||
payload.distant_ip,
|
||||
payload.distant_port,
|
||||
payload.distant_reserved,
|
||||
payload.device_name,
|
||||
payload.manufacturer_name,
|
||||
payload.application_name,
|
||||
payload.host_name,
|
||||
payload.user_name,
|
||||
payload.user_comment,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_packet(cls, framecounter: int) -> bytes:
|
||||
"""Creates a complete PING packet with header and payload."""
|
||||
data = bytearray()
|
||||
data.extend(VbanPingHeader.to_bytes(framecounter))
|
||||
data.extend(cls.to_bytes())
|
||||
return bytes(data)
|
||||
|
||||
@staticmethod
|
||||
def detect_server_type(pong_data: bytes) -> VbanServerType:
|
||||
"""Detect server type from PONG response packet.
|
||||
|
||||
Args:
|
||||
pong_data: Raw bytes from PONG response packet
|
||||
|
||||
Returns:
|
||||
VbanServerType enum indicating the detected server type
|
||||
"""
|
||||
try:
|
||||
if len(pong_data) >= 32:
|
||||
frame_counter_bytes = pong_data[28:32]
|
||||
frame_counter = int.from_bytes(frame_counter_bytes, 'little')
|
||||
|
||||
if frame_counter == VbanServerType.MATRIX.value:
|
||||
return VbanServerType.MATRIX
|
||||
elif frame_counter == VbanServerType.VOICEMEETER.value:
|
||||
return VbanServerType.VOICEMEETER
|
||||
|
||||
return VbanServerType.UNKNOWN
|
||||
|
||||
except Exception:
|
||||
return VbanServerType.UNKNOWN
|
||||
138
vban_cmd/recorder.py
Normal file
138
vban_cmd/recorder.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from .error import VBANCMDError
|
||||
from .iremote import IRemote
|
||||
from .meta import action_fn
|
||||
|
||||
|
||||
class Recorder(IRemote):
|
||||
"""
|
||||
Implements the common interface
|
||||
|
||||
Defines concrete implementation for recorder
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def make(cls, remote):
|
||||
"""
|
||||
Factory function for recorder class.
|
||||
|
||||
Returns a Recorder class of a kind.
|
||||
"""
|
||||
Recorder_cls = type(
|
||||
f'Recorder{remote.kind}',
|
||||
(cls,),
|
||||
{
|
||||
**{
|
||||
param: action_fn(param)
|
||||
for param in [
|
||||
'play',
|
||||
'stop',
|
||||
'pause',
|
||||
'replay',
|
||||
'record',
|
||||
'ff',
|
||||
'rew',
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
return Recorder_cls(remote)
|
||||
|
||||
def __str__(self):
|
||||
return f'{type(self).__name__}'
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return 'recorder'
|
||||
|
||||
@property
|
||||
def samplerate(self) -> int:
|
||||
return
|
||||
|
||||
@samplerate.setter
|
||||
def samplerate(self, val: int):
|
||||
opts = (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
|
||||
if val not in opts:
|
||||
self.logger.warning(f'samplerate got: {val} but expected a value in {opts}')
|
||||
self.setter('samplerate', val)
|
||||
|
||||
@property
|
||||
def bitresolution(self) -> int:
|
||||
return
|
||||
|
||||
@bitresolution.setter
|
||||
def bitresolution(self, val: int):
|
||||
opts = (8, 16, 24, 32)
|
||||
if val not in opts:
|
||||
self.logger.warning(
|
||||
f'bitresolution got: {val} but expected a value in {opts}'
|
||||
)
|
||||
self.setter('bitresolution', val)
|
||||
|
||||
@property
|
||||
def channel(self) -> int:
|
||||
return
|
||||
|
||||
@channel.setter
|
||||
def channel(self, val: int):
|
||||
if not 1 <= val <= 8:
|
||||
self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
|
||||
self.setter('channel', val)
|
||||
|
||||
@property
|
||||
def kbps(self):
|
||||
return
|
||||
|
||||
@kbps.setter
|
||||
def kbps(self, val: int):
|
||||
opts = (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
|
||||
if val not in opts:
|
||||
self.logger.warning(f'kbps got: {val} but expected a value in {opts}')
|
||||
self.setter('kbps', val)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
return
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter('gain', val)
|
||||
|
||||
def load(self, file: os.PathLike):
|
||||
try:
|
||||
# Convert to string, use forward slashes, and wrap in quotes for spaces
|
||||
file_path = f'"{os.fspath(file).replace(chr(92), "/")}"'
|
||||
self.setter('load', file_path)
|
||||
except UnicodeError:
|
||||
raise VBANCMDError('File full directory must be a raw string')
|
||||
|
||||
def goto(self, time_str):
|
||||
def get_sec():
|
||||
"""Get seconds from time string"""
|
||||
h, m, s = time_str.split(':')
|
||||
return int(h) * 3600 + int(m) * 60 + int(s)
|
||||
|
||||
time_str = str(time_str) # coerce the type
|
||||
if (
|
||||
re.match(
|
||||
r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$',
|
||||
time_str,
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self.setter('goto', get_sec())
|
||||
else:
|
||||
self.logger.warning(
|
||||
"goto expects a string that matches the format 'hh:mm:ss'"
|
||||
)
|
||||
|
||||
def filetype(self, val: str):
|
||||
opts = {'wav': 1, 'aiff': 2, 'bwf': 3, 'mp3': 100}
|
||||
try:
|
||||
self.setter('filetype', opts[val.lower()])
|
||||
except KeyError:
|
||||
self.logger.warning(
|
||||
f'filetype got: {val} but expected a value in {list(opts.keys())}'
|
||||
)
|
||||
@@ -1,9 +1,17 @@
|
||||
from abc import abstractmethod
|
||||
import abc
|
||||
import time
|
||||
from typing import Union
|
||||
|
||||
from . import kinds
|
||||
from .enums import NBS
|
||||
from .iremote import IRemote
|
||||
from .kinds import kinds_all
|
||||
from .meta import channel_bool_prop, channel_label_prop, strip_output_prop
|
||||
from .meta import (
|
||||
channel_bool_prop,
|
||||
channel_label_prop,
|
||||
send_prop,
|
||||
strip_output_prop,
|
||||
xy_prop,
|
||||
)
|
||||
|
||||
|
||||
class Strip(IRemote):
|
||||
@@ -13,13 +21,13 @@ class Strip(IRemote):
|
||||
Defines concrete implementation for strip
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
@@ -27,64 +35,491 @@ class Strip(IRemote):
|
||||
|
||||
@limit.setter
|
||||
def limit(self, val: int):
|
||||
self.setter("limit", val)
|
||||
self.setter('limit', val)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
val = self.getter("gain")
|
||||
val = self.getter('gain')
|
||||
if val is None:
|
||||
val = self.gainlayer[0].gain
|
||||
val = max(layer.gain for layer in self.gainlayer)
|
||||
return round(val, 1)
|
||||
|
||||
@gain.setter
|
||||
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):
|
||||
@classmethod
|
||||
def make(cls, remote, index, is_phys):
|
||||
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
|
||||
return type(
|
||||
f'PhysicalStrip{remote.kind}',
|
||||
(cls, EFFECTS_cls),
|
||||
{
|
||||
'comp': StripComp(remote, index),
|
||||
'gate': StripGate(remote, index),
|
||||
'denoiser': StripDenoiser(remote, index),
|
||||
'eq': StripEQ.make(remote, index),
|
||||
},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
@property
|
||||
def comp(self) -> float:
|
||||
return
|
||||
def audibility(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].audibility.knob
|
||||
|
||||
@comp.setter
|
||||
def comp(self, val: float):
|
||||
self.setter("Comp", val)
|
||||
@audibility.setter
|
||||
def audibility(self, val: float):
|
||||
self.setter('audibility', val)
|
||||
|
||||
|
||||
class StripComp(IRemote):
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f'strip[{self.index}].comp'
|
||||
|
||||
@property
|
||||
def gate(self) -> float:
|
||||
return
|
||||
def knob(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].audibility.comp
|
||||
|
||||
@gate.setter
|
||||
def gate(self, val: float):
|
||||
self.setter("gate", val)
|
||||
@knob.setter
|
||||
def knob(self, val: float):
|
||||
self.setter('', val)
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
return
|
||||
def gainin(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.gain_in
|
||||
|
||||
@gainin.setter
|
||||
def gainin(self, val: float):
|
||||
self.setter('GainIn', val)
|
||||
|
||||
@property
|
||||
def sr(self):
|
||||
def ratio(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.ratio
|
||||
|
||||
@ratio.setter
|
||||
def ratio(self, val: float):
|
||||
self.setter('Ratio', val)
|
||||
|
||||
@property
|
||||
def threshold(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.threshold
|
||||
|
||||
@threshold.setter
|
||||
def threshold(self, val: float):
|
||||
self.setter('Threshold', val)
|
||||
|
||||
@property
|
||||
def attack(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.attack_ms
|
||||
|
||||
@attack.setter
|
||||
def attack(self, val: float):
|
||||
self.setter('Attack', val)
|
||||
|
||||
@property
|
||||
def release(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.release_ms
|
||||
|
||||
@release.setter
|
||||
def release(self, val: float):
|
||||
self.setter('Release', val)
|
||||
|
||||
@property
|
||||
def knee(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.n_knee
|
||||
|
||||
@knee.setter
|
||||
def knee(self, val: float):
|
||||
self.setter('Knee', val)
|
||||
|
||||
@property
|
||||
def gainout(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].compressor.gain_out
|
||||
|
||||
@gainout.setter
|
||||
def gainout(self, val: float):
|
||||
self.setter('GainOut', val)
|
||||
|
||||
@property
|
||||
def makeup(self) -> bool:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return False
|
||||
return bool(self.public_packets[NBS.one].strips[self.index].compressor.makeup)
|
||||
|
||||
@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:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].audibility.gate
|
||||
|
||||
@knob.setter
|
||||
def knob(self, val: float):
|
||||
self.setter('', val)
|
||||
|
||||
@property
|
||||
def threshold(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.threshold_in
|
||||
|
||||
@threshold.setter
|
||||
def threshold(self, val: float):
|
||||
self.setter('Threshold', val)
|
||||
|
||||
@property
|
||||
def damping(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.damping_max
|
||||
|
||||
@damping.setter
|
||||
def damping(self, val: float):
|
||||
self.setter('Damping', val)
|
||||
|
||||
@property
|
||||
def bpsidechain(self) -> int:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.bp_sidechain
|
||||
|
||||
@bpsidechain.setter
|
||||
def bpsidechain(self, val: int):
|
||||
self.setter('BPSidechain', val)
|
||||
|
||||
@property
|
||||
def attack(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.attack_ms
|
||||
|
||||
@attack.setter
|
||||
def attack(self, val: float):
|
||||
self.setter('Attack', val)
|
||||
|
||||
@property
|
||||
def hold(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.hold_ms
|
||||
|
||||
@hold.setter
|
||||
def hold(self, val: float):
|
||||
self.setter('Hold', val)
|
||||
|
||||
@property
|
||||
def release(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].gate.release_ms
|
||||
|
||||
@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:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].audibility.denoiser
|
||||
|
||||
@knob.setter
|
||||
def knob(self, val: float):
|
||||
self.setter('', val)
|
||||
|
||||
|
||||
class StripEQ(IRemote):
|
||||
@classmethod
|
||||
def make(cls, remote, i):
|
||||
"""
|
||||
Factory method for Strip EQ.
|
||||
|
||||
Returns a StripEQ class.
|
||||
"""
|
||||
STRIPEQ_cls = type(
|
||||
'StripEQ',
|
||||
(cls,),
|
||||
{
|
||||
'channel': tuple(
|
||||
StripEQCh.make(remote, i, j)
|
||||
for j in range(remote.kind.strip_channels)
|
||||
)
|
||||
},
|
||||
)
|
||||
return STRIPEQ_cls(remote, i)
|
||||
|
||||
@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 StripEQCh(IRemote):
|
||||
@classmethod
|
||||
def make(cls, remote, i, j):
|
||||
"""
|
||||
Factory method for Strip EQ channel.
|
||||
|
||||
Returns a StripEQCh class.
|
||||
"""
|
||||
StripEQCh_cls = type(
|
||||
'StripEQCh',
|
||||
(cls,),
|
||||
{
|
||||
'cell': tuple(
|
||||
StripEQChCell(remote, i, j, k) for k in range(remote.kind.cells)
|
||||
)
|
||||
},
|
||||
)
|
||||
return StripEQCh_cls(remote, i, j)
|
||||
|
||||
def __init__(self, remote, i, j):
|
||||
super().__init__(remote, i)
|
||||
self.channel_index = j
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f'Strip[{self.index}].eq.channel[{self.channel_index}]'
|
||||
|
||||
|
||||
class StripEQChCell(IRemote):
|
||||
def __init__(self, remote, i, j, k):
|
||||
super().__init__(remote, i)
|
||||
self.channel_index = j
|
||||
self.cell_index = k
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f'Strip[{self.index}].eq.channel[{self.channel_index}].cell[{self.cell_index}]'
|
||||
|
||||
@property
|
||||
def on(self) -> bool:
|
||||
if self.channel_index > 0:
|
||||
self.logger.warning(
|
||||
'Only channel 0 is supported over VBAN for Strip EQ cells'
|
||||
)
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return False
|
||||
return (
|
||||
self.public_packets[NBS.one]
|
||||
.strips[self.index]
|
||||
.parametric_eq[self.cell_index]
|
||||
.on
|
||||
)
|
||||
|
||||
@on.setter
|
||||
def on(self, val: bool):
|
||||
self.setter('on', 1 if val else 0)
|
||||
|
||||
@property
|
||||
def type(self) -> int:
|
||||
if self.channel_index > 0:
|
||||
self.logger.warning(
|
||||
'Only channel 0 is supported over VBAN for Strip EQ cells'
|
||||
)
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0
|
||||
return (
|
||||
self.public_packets[NBS.one]
|
||||
.strips[self.index]
|
||||
.parametric_eq[self.cell_index]
|
||||
.type
|
||||
)
|
||||
|
||||
@type.setter
|
||||
def type(self, val: int):
|
||||
self.setter('type', val)
|
||||
|
||||
@property
|
||||
def f(self) -> float:
|
||||
if self.channel_index > 0:
|
||||
self.logger.warning(
|
||||
'Only channel 0 is supported over VBAN for Strip EQ cells'
|
||||
)
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return (
|
||||
self.public_packets[NBS.one]
|
||||
.strips[self.index]
|
||||
.parametric_eq[self.cell_index]
|
||||
.freq
|
||||
)
|
||||
|
||||
@f.setter
|
||||
def f(self, val: float):
|
||||
self.setter('f', val)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
if self.channel_index > 0:
|
||||
self.logger.warning(
|
||||
'Only channel 0 is supported over VBAN for Strip EQ cells'
|
||||
)
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return (
|
||||
self.public_packets[NBS.one]
|
||||
.strips[self.index]
|
||||
.parametric_eq[self.cell_index]
|
||||
.gain
|
||||
)
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter('gain', val)
|
||||
|
||||
@property
|
||||
def q(self) -> float:
|
||||
if self.channel_index > 0:
|
||||
self.logger.warning(
|
||||
'Only channel 0 is supported over VBAN for Strip EQ cells'
|
||||
)
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return (
|
||||
self.public_packets[NBS.one]
|
||||
.strips[self.index]
|
||||
.parametric_eq[self.cell_index]
|
||||
.q
|
||||
)
|
||||
|
||||
@q.setter
|
||||
def q(self, val: float):
|
||||
self.setter('q', val)
|
||||
|
||||
|
||||
class VirtualStrip(Strip):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
@classmethod
|
||||
def make(cls, remote, i, is_phys):
|
||||
"""
|
||||
Factory method for VirtualStrip.
|
||||
|
||||
mc = channel_bool_prop("mc")
|
||||
Returns a VirtualStrip class.
|
||||
"""
|
||||
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
|
||||
return type(
|
||||
'VirtualStrip',
|
||||
(cls, EFFECTS_cls),
|
||||
{},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
mc = channel_bool_prop('mc')
|
||||
|
||||
mono = mc
|
||||
|
||||
@property
|
||||
def k(self) -> int:
|
||||
return
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0
|
||||
return self.public_packets[NBS.one].strips[self.index].karaoke
|
||||
|
||||
@k.setter
|
||||
def k(self, val: int):
|
||||
self.setter("karaoke", val)
|
||||
self.setter('karaoke', val)
|
||||
|
||||
@property
|
||||
def bass(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.bass
|
||||
|
||||
@bass.setter
|
||||
def bass(self, val: float):
|
||||
self.setter('EQGain1', val)
|
||||
|
||||
@property
|
||||
def mid(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.mid
|
||||
|
||||
@mid.setter
|
||||
def mid(self, val: float):
|
||||
self.setter('EQGain2', val)
|
||||
|
||||
med = mid
|
||||
|
||||
@property
|
||||
def treble(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.treble
|
||||
|
||||
@treble.setter
|
||||
def treble(self, val: float):
|
||||
self.setter('EQGain3', val)
|
||||
|
||||
high = treble
|
||||
|
||||
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):
|
||||
@@ -103,14 +538,17 @@ class StripLevel(IRemote):
|
||||
self.range = self.level_map[self.index]
|
||||
|
||||
def getter(self):
|
||||
return tuple(
|
||||
round(-i * 0.01, 1)
|
||||
for i in self._remote.cache["strip_level"][self.range[0] : self.range[-1]]
|
||||
)
|
||||
"""Returns a tuple of level values for the channel."""
|
||||
|
||||
if not self._remote.stopped() and self._remote.event.ldirty:
|
||||
return self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
|
||||
return self.public_packets[NBS.zero].levels.strip[
|
||||
self.range[0] : self.range[-1]
|
||||
]
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def prefader(self) -> tuple:
|
||||
@@ -143,36 +581,28 @@ class GainLayer(IRemote):
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
def fget():
|
||||
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index]
|
||||
if val < 10000:
|
||||
return -val
|
||||
elif val == ((1 << 16) - 1):
|
||||
return 0
|
||||
val = self.getter(f'GainLayer[{self._i}]')
|
||||
if val:
|
||||
return round(val, 2)
|
||||
else:
|
||||
return ((1 << 16) - 1) - val
|
||||
|
||||
val = self.getter(f"GainLayer[{self._i}]")
|
||||
if val is None:
|
||||
val = fget() * 0.01
|
||||
return round(val, 1)
|
||||
return self.public_packets[NBS.zero].gainlayers[self._i][self.index]
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter(f"GainLayer[{self._i}]", val)
|
||||
self.setter(f'GainLayer[{self._i}]', val)
|
||||
|
||||
|
||||
def _make_gainlayer_mixin(remote, index):
|
||||
"""Creates a GainLayer mixin"""
|
||||
return type(
|
||||
f"GainlayerMixin",
|
||||
'GainlayerMixin',
|
||||
(),
|
||||
{
|
||||
"gainlayer": tuple(
|
||||
'gainlayer': tuple(
|
||||
GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
|
||||
)
|
||||
},
|
||||
@@ -182,24 +612,78 @@ def _make_gainlayer_mixin(remote, index):
|
||||
def _make_channelout_mixin(kind):
|
||||
"""Creates a channel out property mixin"""
|
||||
return type(
|
||||
f"ChannelOutMixin{kind}",
|
||||
f'ChannelOutMixin{kind}',
|
||||
(),
|
||||
{
|
||||
**{
|
||||
f"A{i}": strip_output_prop(f"A{i}") for i in range(1, kind.phys_out + 1)
|
||||
f'A{i}': strip_output_prop(f'A{i}') for i in range(1, kind.phys_out + 1)
|
||||
},
|
||||
**{
|
||||
f"B{i}": strip_output_prop(f"B{i}") for i in range(1, kind.virt_out + 1)
|
||||
f'B{i}': strip_output_prop(f'B{i}') for i in range(1, kind.virt_out + 1)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_make_channelout_mixins = {
|
||||
kind.name: _make_channelout_mixin(kind) for kind in kinds_all
|
||||
kind.name: _make_channelout_mixin(kind) for kind in kinds.all
|
||||
}
|
||||
|
||||
|
||||
def _make_effects_mixin(kind, is_phys):
|
||||
"""creates an effects mixin for a kind"""
|
||||
|
||||
def _make_xy_cls():
|
||||
pan = {param: xy_prop(param) for param in ['pan_x', 'pan_y']}
|
||||
color = {param: xy_prop(param) for param in ['color_x', 'color_y']}
|
||||
fx = {param: xy_prop(param) for param in ['fx_x', 'fx_y']}
|
||||
if is_phys:
|
||||
return type(
|
||||
'XYPhys',
|
||||
(),
|
||||
{
|
||||
**pan,
|
||||
**color,
|
||||
**fx,
|
||||
},
|
||||
)
|
||||
return type(
|
||||
'XYVirt',
|
||||
(),
|
||||
{**pan},
|
||||
)
|
||||
|
||||
def _make_sends_cls():
|
||||
if is_phys:
|
||||
return type(
|
||||
'FX',
|
||||
(),
|
||||
{
|
||||
**{
|
||||
param: send_prop(param)
|
||||
for param in ['reverb', 'delay', 'fx1', 'fx2']
|
||||
},
|
||||
# **{
|
||||
# f'post{param}': bool_prop(f'post{param}')
|
||||
# for param in ['reverb', 'delay', 'fx1', 'fx2']
|
||||
# },
|
||||
},
|
||||
)
|
||||
return type('FX', (), {})
|
||||
|
||||
if kind.name == 'basic':
|
||||
steps = (_make_xy_cls,)
|
||||
elif kind.name == 'banana':
|
||||
steps = (_make_xy_cls,)
|
||||
elif kind.name == 'potato':
|
||||
steps = (_make_xy_cls, _make_sends_cls)
|
||||
return type(f'Effects{kind}', tuple(step() for step in steps), {})
|
||||
|
||||
|
||||
def _make_effects_mixins(is_phys):
|
||||
return {kind.name: _make_effects_mixin(kind, is_phys) for kind in kinds.all}
|
||||
|
||||
|
||||
def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]:
|
||||
"""
|
||||
Factory method for strips
|
||||
@@ -208,17 +692,21 @@ def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip
|
||||
|
||||
Returns a physical or virtual strip subclass
|
||||
"""
|
||||
STRIP_cls = PhysicalStrip if is_phys_strip else VirtualStrip
|
||||
STRIP_cls = (
|
||||
PhysicalStrip.make(remote, i, is_phys_strip)
|
||||
if is_phys_strip
|
||||
else VirtualStrip.make(remote, i, is_phys_strip)
|
||||
)
|
||||
CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
|
||||
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
|
||||
|
||||
return type(
|
||||
f"{STRIP_cls.__name__}{remote.kind}",
|
||||
f'{STRIP_cls.__name__}{remote.kind}',
|
||||
(STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls),
|
||||
{
|
||||
"levels": StripLevel(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ["mono", "solo", "mute"]},
|
||||
"label": channel_label_prop(),
|
||||
'levels': StripLevel(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ['mono', 'solo', 'mute']},
|
||||
'label': channel_label_prop(),
|
||||
},
|
||||
)(remote, i)
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
class Subject:
|
||||
"""Adds support for observers"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Subject:
|
||||
def __init__(self):
|
||||
"""list of current observers"""
|
||||
"""Adds support for observers and callbacks"""
|
||||
|
||||
self._observers = list()
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
|
||||
@property
|
||||
def observers(self) -> list:
|
||||
@@ -12,28 +16,57 @@ class Subject:
|
||||
|
||||
return self._observers
|
||||
|
||||
def notify(self, modifier=None):
|
||||
def notify(self, event):
|
||||
"""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):
|
||||
"""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"""
|
||||
"""adds an observer to observers"""
|
||||
|
||||
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:
|
||||
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):
|
||||
"""clears the _observers list"""
|
||||
"""clears the observers list"""
|
||||
|
||||
self._observers.clear()
|
||||
|
||||
149
vban_cmd/util.py
149
vban_cmd/util.py
@@ -1,15 +1,86 @@
|
||||
from enum import IntEnum
|
||||
import socket
|
||||
import time
|
||||
from typing import Iterator
|
||||
|
||||
from .error import VBANCMDConnectionError
|
||||
|
||||
|
||||
def ratelimit(func):
|
||||
"""ratelimit decorator for {VbanCmd}.sendtext, to prevent flooding the network with script requests."""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self.script_ratelimit > 0:
|
||||
now = time.time()
|
||||
elapsed = now - self._last_script_request_time
|
||||
if elapsed < self.script_ratelimit:
|
||||
time.sleep(self.script_ratelimit - elapsed)
|
||||
self._last_script_request_time = time.time()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def pong_timeout(func):
|
||||
"""pong_timeout decorator for {VbanCmd}._handle_pong, to handle timeout logic and socket management."""
|
||||
|
||||
def wrapper(self, timeout: float = None):
|
||||
if timeout is None:
|
||||
timeout = min(self.timeout, 3.0)
|
||||
|
||||
original_timeout = self.sock.gettimeout()
|
||||
self.sock.settimeout(0.5)
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
response_count = 0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response_count += 1
|
||||
|
||||
if func(self):
|
||||
return
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
self.logger.debug(
|
||||
f'PING timeout after {timeout}s, received {response_count} non-PONG packets'
|
||||
)
|
||||
raise VBANCMDConnectionError(
|
||||
f'PING timeout: No response from {self.host}:{self.port} after {timeout}s'
|
||||
)
|
||||
|
||||
finally:
|
||||
self.sock.settimeout(original_timeout)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_bool(func, param):
|
||||
"""Check cache for a bool prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
cmd = f"{self.identifier}.{param}"
|
||||
if cmd in self._remote.cache:
|
||||
return self._remote.cache.pop(cmd) == 1
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param)) == 1
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_int(func, param):
|
||||
"""Check cache for an int prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param))
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -20,9 +91,24 @@ def cache_string(func, param):
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
cmd = f"{self.identifier}.{param}"
|
||||
if cmd in self._remote.cache:
|
||||
return self._remote.cache.pop(cmd)
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param)).strip('"')
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_float(func, param):
|
||||
"""Check cache for a float prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return round(self._remote.cache.pop(self._cmd(param)), 2)
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -34,37 +120,34 @@ def depth(d):
|
||||
return 0
|
||||
|
||||
|
||||
def script(func):
|
||||
"""Convert dictionary to script"""
|
||||
|
||||
def wrapper(*args):
|
||||
remote, script = args
|
||||
if isinstance(script, dict):
|
||||
params = ""
|
||||
for key, val in script.items():
|
||||
obj, m2, *rem = key.split("-")
|
||||
index = int(m2) if m2.isnumeric() else int(*rem)
|
||||
params += ";".join(
|
||||
f"{obj}{f'.{m2}stream' if not m2.isnumeric() else ''}[{index}].{k}={int(v) if isinstance(v, bool) else v}"
|
||||
for k, v in val.items()
|
||||
)
|
||||
params += ";"
|
||||
script = params
|
||||
return func(remote, script)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
|
||||
"""
|
||||
Generator function, accepts two tuples.
|
||||
Generator function, accepts two tuples of dB values.
|
||||
|
||||
Evaluates equality of each member in both tuples.
|
||||
Returns True when levels are equal (no change), False when different.
|
||||
"""
|
||||
|
||||
for a, b in zip(t0, t1):
|
||||
if b <= 9500:
|
||||
yield a == b
|
||||
yield True
|
||||
|
||||
|
||||
Socket = IntEnum("Socket", "register request response", start=0)
|
||||
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]
|
||||
|
||||
|
||||
def bump_framecounter(framecounter: int) -> int:
|
||||
"""Increment framecounter with rollover at 0xFFFFFFFF."""
|
||||
if framecounter > 0xFFFFFFFF:
|
||||
return 0
|
||||
else:
|
||||
return framecounter + 1
|
||||
|
||||
234
vban_cmd/vban.py
Normal file
234
vban_cmd/vban.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import abc
|
||||
|
||||
from . import kinds
|
||||
from .iremote import IRemote
|
||||
|
||||
|
||||
class VbanStream(IRemote):
|
||||
"""
|
||||
Implements the common interface
|
||||
|
||||
Defines concrete implementation for vban stream
|
||||
"""
|
||||
|
||||
@abc.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)
|
||||
314
vban_cmd/vbancmd.py
Normal file
314
vban_cmd/vbancmd.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import abc
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Mapping, Union
|
||||
|
||||
from .enums import NBS
|
||||
from .error import VBANCMDConnectionError, VBANCMDError
|
||||
from .event import Event
|
||||
from .packet.headers import (
|
||||
VbanMatrixResponseHeader,
|
||||
VbanPongHeader,
|
||||
VbanRTRequestHeader,
|
||||
)
|
||||
from .packet.ping0 import VbanPing0Payload, VbanServerType
|
||||
from .subject import Subject
|
||||
from .util import bump_framecounter, deep_merge, pong_timeout, ratelimit
|
||||
from .worker import Producer, Subscriber, Updater
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VbanCmd(abc.ABC):
|
||||
"""Abstract Base Class for Voicemeeter VBAN Command Interfaces"""
|
||||
|
||||
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['host']:
|
||||
kwargs |= self._conn_from_toml()
|
||||
for attr, val in kwargs.items():
|
||||
setattr(self, attr, val)
|
||||
|
||||
self._framecounter = 0
|
||||
self._framecounter_lock = threading.Lock()
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.subject = self.observer = Subject()
|
||||
self.cache = {}
|
||||
self._pdirty = False
|
||||
self._ldirty = False
|
||||
self.stop_event = None
|
||||
self.producer = None
|
||||
self._last_script_request_time = 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
"""Ensure subclasses override str magic method"""
|
||||
|
||||
def _conn_from_toml(self) -> dict:
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore[import]
|
||||
|
||||
def get_filepath():
|
||||
for pn in (
|
||||
Path.cwd() / 'vban.toml',
|
||||
Path.cwd() / 'configs' / 'vban.toml',
|
||||
Path.home() / '.config' / 'vban-cmd' / 'vban.toml',
|
||||
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / 'vban.toml',
|
||||
):
|
||||
if pn.exists():
|
||||
return pn
|
||||
|
||||
if not (filepath := get_filepath()):
|
||||
raise VBANCMDError('no ip provided and no vban.toml located.')
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
return tomllib.load(f)['connection']
|
||||
except tomllib.TomlDecodeError as e:
|
||||
raise VBANCMDError(f'Error decoding {filepath}: {e}') from e
|
||||
|
||||
def __enter__(self):
|
||||
self.login()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
self.logout()
|
||||
|
||||
def login(self) -> None:
|
||||
"""Sends a PING packet to the VBAN server to verify connectivity and detect server type.
|
||||
If the server is detected as Matrix, RT listeners will be disabled for compatibility.
|
||||
"""
|
||||
self._ping()
|
||||
self._handle_pong()
|
||||
|
||||
if not self.disable_rt_listeners:
|
||||
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 host='{host}', port={port}, streamname='{streamname}'".format(
|
||||
**self.__dict__
|
||||
)
|
||||
)
|
||||
|
||||
def logout(self) -> None:
|
||||
if not self.stopped():
|
||||
self.logger.debug('events thread shutdown started')
|
||||
self.stop_event.set()
|
||||
if self.producer is not None:
|
||||
for t in (self.producer, self.subscriber):
|
||||
t.join()
|
||||
self.sock.close()
|
||||
self.logger.info(f'{type(self).__name__}: Successfully logged out of {self}')
|
||||
|
||||
def stopped(self):
|
||||
return self.stop_event is None or self.stop_event.is_set()
|
||||
|
||||
def _get_next_framecounter(self) -> int:
|
||||
"""Thread-safe method to get and increment framecounter."""
|
||||
with self._framecounter_lock:
|
||||
current = self._framecounter
|
||||
self._framecounter = bump_framecounter(self._framecounter)
|
||||
return current
|
||||
|
||||
def _ping(self):
|
||||
"""Initiates the PING/PONG handshake with the VBAN server."""
|
||||
try:
|
||||
self.sock.sendto(
|
||||
VbanPing0Payload.create_packet(self._get_next_framecounter()),
|
||||
(socket.gethostbyname(self.host), self.port),
|
||||
)
|
||||
self.logger.debug(f'PING sent to {self.host}:{self.port}')
|
||||
|
||||
except socket.gaierror as e:
|
||||
raise VBANCMDConnectionError(
|
||||
f'Unable to resolve hostname {self.host}'
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise VBANCMDConnectionError(f'PING failed: {e}') from e
|
||||
|
||||
@pong_timeout
|
||||
def _handle_pong(self) -> bool:
|
||||
"""Handles incoming packets during the PING/PONG handshake, looking for a valid PONG response to confirm connectivity and detect server type.
|
||||
|
||||
Returns True if a valid PONG is received, False otherwise."""
|
||||
data, addr = self.sock.recvfrom(2048)
|
||||
|
||||
if VbanPongHeader.is_pong_response(data):
|
||||
self.logger.debug(f'PONG received from {addr}, connectivity confirmed')
|
||||
|
||||
server_type = VbanPing0Payload.detect_server_type(data)
|
||||
self._handle_server_type(server_type)
|
||||
|
||||
return True
|
||||
else:
|
||||
if len(data) >= 8:
|
||||
if data[:4] == b'VBAN':
|
||||
protocol = data[4] & 0xE0
|
||||
nbc = data[6]
|
||||
self.logger.debug(
|
||||
f'Non-PONG VBAN packet: protocol=0x{protocol:02x}, nbc=0x{nbc:02x}'
|
||||
)
|
||||
else:
|
||||
self.logger.debug('Non-VBAN packet received')
|
||||
|
||||
return False
|
||||
|
||||
def _handle_server_type(self, server_type: VbanServerType) -> None:
|
||||
"""Handle the detected server type by adjusting settings accordingly."""
|
||||
match server_type:
|
||||
case VbanServerType.VOICEMEETER:
|
||||
self.logger.debug(
|
||||
'Detected Voicemeeter VBAN server - RT listeners supported'
|
||||
)
|
||||
case VbanServerType.MATRIX:
|
||||
self.logger.info(
|
||||
'Detected Matrix VBAN server - disabling RT listeners for compatibility'
|
||||
)
|
||||
self.disable_rt_listeners = True
|
||||
case _:
|
||||
self.logger.debug(
|
||||
f'Unknown server type ({server_type}) - using default settings'
|
||||
)
|
||||
|
||||
def _send_request(self, payload: str) -> None:
|
||||
"""Sends a request packet over the network and bumps the framecounter."""
|
||||
self.sock.sendto(
|
||||
VbanRTRequestHeader.encode_with_payload(
|
||||
name=self.streamname,
|
||||
bps_index=self.BPS_OPTS.index(self.bps),
|
||||
channel=self.channel,
|
||||
framecounter=self._get_next_framecounter(),
|
||||
payload=payload,
|
||||
),
|
||||
(socket.gethostbyname(self.host), self.port),
|
||||
)
|
||||
|
||||
def _set_rt(self, cmd: str, val: Union[str, float]):
|
||||
"""Sends a string request command over a network."""
|
||||
self._send_request(f'{cmd}={val};')
|
||||
self.cache[cmd] = val
|
||||
|
||||
@ratelimit
|
||||
def sendtext(self, script) -> str | None:
|
||||
"""Sends a multiple parameter string over a network."""
|
||||
self._send_request(script)
|
||||
self.logger.debug(f'sendtext: {script}')
|
||||
|
||||
if self.disable_rt_listeners and script.endswith(('?', '?;')):
|
||||
try:
|
||||
data, _ = self.sock.recvfrom(2048)
|
||||
return VbanMatrixResponseHeader.extract_payload(data)
|
||||
except ValueError as e:
|
||||
self.logger.warning(f'Error extracting matrix response: {e}')
|
||||
except TimeoutError as e:
|
||||
self.logger.exception(f'Timeout waiting for matrix response: {e}')
|
||||
raise VBANCMDConnectionError(
|
||||
f'Timeout waiting for response from {self.host}:{self.port}'
|
||||
) from e
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Returns the type of Voicemeeter installation."""
|
||||
return self.public_packets[NBS.zero].voicemeetertype
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Returns Voicemeeter's version as a string"""
|
||||
return '{0}.{1}.{2}.{3}'.format(
|
||||
*self.public_packets[NBS.zero].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_packets(self):
|
||||
return self._public_packets
|
||||
|
||||
def clear_dirty(self) -> None:
|
||||
while self.pdirty:
|
||||
time.sleep(self.DELAY)
|
||||
|
||||
def apply(self, data: Mapping):
|
||||
"""Set all parameters of a dict"""
|
||||
|
||||
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)]
|
||||
|
||||
for key, di in data.items():
|
||||
target(key).apply(di)
|
||||
|
||||
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!")
|
||||
@@ -1,40 +1,129 @@
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
|
||||
from .packet import (
|
||||
from .enums import NBS
|
||||
from .error import VBANCMDConnectionError
|
||||
from .packet.headers import (
|
||||
HEADER_SIZE,
|
||||
RegisterRTHeader,
|
||||
VBAN_VMRT_Packet_Data,
|
||||
VBAN_VMRT_Packet_Header,
|
||||
VbanRTPacket,
|
||||
VbanRTResponseHeader,
|
||||
VbanRTSubscribeHeader,
|
||||
)
|
||||
from .util import Socket
|
||||
from .packet.nbs0 import VbanRTPacketNBS0
|
||||
from .packet.nbs1 import VbanRTPacketNBS1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Subscriber(threading.Thread):
|
||||
"""fire a subscription packet every 10 seconds"""
|
||||
|
||||
def __init__(self, remote):
|
||||
super().__init__(name="subscriber", target=self.register, daemon=True)
|
||||
self._rem = remote
|
||||
self.register_header = RegisterRTHeader()
|
||||
def __init__(self, remote, stop_event):
|
||||
super().__init__(name='subscriber', daemon=False)
|
||||
self._remote = remote
|
||||
self.stop_event = stop_event
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
|
||||
def register(self):
|
||||
while self._rem.running:
|
||||
try:
|
||||
self._rem.socks[Socket.register].sendto(
|
||||
self.register_header.header,
|
||||
(socket.gethostbyname(self._rem.ip), self._rem.port),
|
||||
def run(self):
|
||||
while not self.stopped():
|
||||
for nbs in NBS:
|
||||
sub_packet = VbanRTSubscribeHeader().to_bytes(
|
||||
nbs, self._remote._get_next_framecounter()
|
||||
)
|
||||
count = int.from_bytes(self.register_header.framecounter, "little") + 1
|
||||
self.register_header.framecounter = count.to_bytes(4, "little")
|
||||
time.sleep(10)
|
||||
except socket.gaierror as e:
|
||||
print(f"Unable to resolve hostname {self._rem.ip}")
|
||||
self._rem.socks[Socket.register].close()
|
||||
raise e
|
||||
self._remote.sock.sendto(
|
||||
sub_packet, (self._remote.host, self._remote.port)
|
||||
)
|
||||
|
||||
self.wait_until_stopped(10)
|
||||
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 Producer(threading.Thread):
|
||||
"""Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
|
||||
|
||||
def __init__(self, remote, queue, stop_event):
|
||||
super().__init__(name='producer', daemon=False)
|
||||
self._remote = remote
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self._remote._public_packets = [None] * (max(NBS) + 1)
|
||||
_pp = self._get_rt()
|
||||
self._remote._public_packets[_pp.nbs] = _pp
|
||||
(
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
) = self._remote.public_packets[NBS.zero].levels
|
||||
|
||||
def _get_rt(self) -> VbanRTPacket:
|
||||
"""Attempt to fetch data packet until a valid one found"""
|
||||
while True:
|
||||
try:
|
||||
data, _ = self._remote.sock.recvfrom(2048)
|
||||
if len(data) < HEADER_SIZE:
|
||||
continue
|
||||
except TimeoutError as e:
|
||||
self.logger.exception(f'{type(e).__name__}: {e}')
|
||||
raise VBANCMDConnectionError(
|
||||
f'timeout waiting for response from {self._remote.host}:{self._remote.port}'
|
||||
) from e
|
||||
|
||||
try:
|
||||
header = VbanRTResponseHeader.from_bytes(data[:HEADER_SIZE])
|
||||
except ValueError as e:
|
||||
self.logger.debug(f'Error parsing response packet: {e}')
|
||||
continue
|
||||
|
||||
match header.format_nbs:
|
||||
case NBS.zero:
|
||||
return VbanRTPacketNBS0.from_bytes(
|
||||
nbs=NBS.zero, kind=self._remote.kind, data=data
|
||||
)
|
||||
|
||||
case NBS.one:
|
||||
return VbanRTPacketNBS1.from_bytes(
|
||||
nbs=NBS.one, kind=self._remote.kind, data=data
|
||||
)
|
||||
|
||||
def stopped(self):
|
||||
return self.stop_event.is_set()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped():
|
||||
pdirty = ldirty = False
|
||||
_pp = self._get_rt()
|
||||
match _pp.nbs:
|
||||
case NBS.zero:
|
||||
ldirty = _pp.ldirty(
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
)
|
||||
pdirty = _pp.pdirty(self._remote.public_packets[NBS.zero])
|
||||
case NBS.one:
|
||||
pdirty = True
|
||||
|
||||
if pdirty or ldirty:
|
||||
self._remote._public_packets[_pp.nbs] = _pp
|
||||
self._remote._pdirty = pdirty
|
||||
self._remote._ldirty = ldirty
|
||||
|
||||
if self._remote.event.pdirty:
|
||||
self.queue.put('pdirty')
|
||||
if self._remote.event.ldirty:
|
||||
self.queue.put('ldirty')
|
||||
self.logger.debug(f'terminating {self.name} thread')
|
||||
self.queue.put(None)
|
||||
|
||||
|
||||
class Updater(threading.Thread):
|
||||
@@ -44,80 +133,31 @@ class Updater(threading.Thread):
|
||||
notifies observers of event updates
|
||||
"""
|
||||
|
||||
def __init__(self, remote):
|
||||
super().__init__(name="updater", target=self.update, daemon=True)
|
||||
self._rem = remote
|
||||
self._rem.socks[Socket.response].bind(
|
||||
(socket.gethostbyname(socket.gethostname()), self._rem.port)
|
||||
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_packets[NBS.zero]._strip_comp,
|
||||
self._remote._public_packets[NBS.zero]._bus_comp,
|
||||
)
|
||||
self.expected_packet = VBAN_VMRT_Packet_Header()
|
||||
self._rem._public_packet = self._get_rt()
|
||||
|
||||
def _fetch_rt_packet(self) -> Optional[VBAN_VMRT_Packet_Data]:
|
||||
"""Returns a valid RT Data Packet or None"""
|
||||
data, _ = self._rem.socks[Socket.response].recvfrom(2048)
|
||||
# check for packet data
|
||||
if len(data) > HEADER_SIZE:
|
||||
# check if packet is of type VBAN
|
||||
if self.expected_packet.header == data[: HEADER_SIZE - 4]:
|
||||
return VBAN_VMRT_Packet_Data(
|
||||
_voicemeeterType=data[28:29],
|
||||
_reserved=data[29:30],
|
||||
_buffersize=data[30:32],
|
||||
_voicemeeterVersion=data[32:36],
|
||||
_optionBits=data[36:40],
|
||||
_samplerate=data[40:44],
|
||||
_inputLeveldB100=data[44:112],
|
||||
_outputLeveldB100=data[112:240],
|
||||
_TransportBit=data[240:244],
|
||||
_stripState=data[244:276],
|
||||
_busState=data[276:308],
|
||||
_stripGaindB100Layer1=data[308:324],
|
||||
_stripGaindB100Layer2=data[324:340],
|
||||
_stripGaindB100Layer3=data[340:356],
|
||||
_stripGaindB100Layer4=data[356:372],
|
||||
_stripGaindB100Layer5=data[372:388],
|
||||
_stripGaindB100Layer6=data[388:404],
|
||||
_stripGaindB100Layer7=data[404:420],
|
||||
_stripGaindB100Layer8=data[420:436],
|
||||
_busGaindB100=data[436:452],
|
||||
_stripLabelUTF8c60=data[452:932],
|
||||
_busLabelUTF8c60=data[932:1412],
|
||||
)
|
||||
|
||||
def _get_rt(self) -> VBAN_VMRT_Packet_Data:
|
||||
"""Attempt to fetch data packet until a valid one found"""
|
||||
|
||||
def fget():
|
||||
data = False
|
||||
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()
|
||||
self._rem._strip_buf, self._rem._bus_buf = self._rem._get_levels(_pp)
|
||||
self._rem._pdirty = _pp.pdirty(self._rem.public_packet)
|
||||
|
||||
if self._rem.event.ldirty and self._rem.ldirty:
|
||||
self._rem.cache["strip_level"] = self._rem._strip_buf
|
||||
self._rem.cache["bus_level"] = self._rem._bus_buf
|
||||
self._rem.subject.notify("ldirty")
|
||||
if self._rem.public_packet != _pp:
|
||||
self._rem._public_packet = _pp
|
||||
if self._rem.event.pdirty and self._rem.pdirty:
|
||||
self._rem.subject.notify("pdirty")
|
||||
elapsed = time.time() - start
|
||||
if self._rem.ratelimit - elapsed > 0:
|
||||
time.sleep(self._rem.ratelimit - elapsed)
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
) = self._remote.public_packets[NBS.zero].levels
|
||||
self._remote.subject.notify(event)
|
||||
self.logger.debug(f'terminating {self.name} thread')
|
||||
|
||||
Reference in New Issue
Block a user