mirror of
https://github.com/onyx-and-iris/vban-cmd-python.git
synced 2026-04-18 13:03:31 +00:00
Compare commits
133 Commits
8b912a2d08
...
v2.9.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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'
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -85,7 +85,7 @@ ipython_config.py
|
|||||||
# pyenv
|
# pyenv
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
# .python-version
|
.python-version
|
||||||
|
|
||||||
# pipenv
|
# pipenv
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
@@ -151,11 +151,13 @@ cython_debug/
|
|||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
# quick test
|
# test files
|
||||||
quick.py
|
test-*.py
|
||||||
|
|
||||||
#config
|
#config
|
||||||
config.toml
|
config.toml
|
||||||
vban.toml
|
vban.toml
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
PING_FEATURE.md
|
||||||
95
CHANGELOG.md
95
CHANGELOG.md
@@ -11,6 +11,101 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
|
|||||||
|
|
||||||
- [x]
|
- [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
|
## [2.0.0] - 2023-06-25
|
||||||
|
|
||||||
This update introduces some breaking changes:
|
This update introduces some breaking changes:
|
||||||
|
|||||||
230
README.md
230
README.md
@@ -1,26 +1,26 @@
|
|||||||
[](https://badge.fury.io/py/vban-cmd)
|
[](https://badge.fury.io/py/vban-cmd)
|
||||||
[](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE)
|
[](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE)
|
||||||
[](https://github.com/psf/black)
|
[](https://python-poetry.org/)
|
||||||
[](https://pycqa.github.io/isort/)
|
[](https://github.com/astral-sh/ruff)
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
# VBAN CMD
|
# VBAN CMD
|
||||||
|
|
||||||
This python interface allows you to get and set Voicemeeter parameter values over a network.
|
This python interface allows you to send Voicemeeter/Matrix commands over a network.
|
||||||
|
|
||||||
It may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python)
|
It offers the same public API as [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python).
|
||||||
|
|
||||||
There is no support for audio transfer in this package, only parameters.
|
Only the VBAN SERVICE/TEXT subprotocols are supported, there is no support for AUDIO or MIDI in this package.
|
||||||
|
|
||||||
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
||||||
|
|
||||||
## Tested against
|
## Tested against
|
||||||
|
|
||||||
- Basic 1.0.8.8
|
- Basic 1.1.2.2
|
||||||
- Banana 2.0.6.8
|
- Banana 2.1.2.2
|
||||||
- Potato 3.0.2.8
|
- Potato 3.1.2.2
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -29,7 +29,9 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
`pip install vban-cmd`
|
```console
|
||||||
|
pip install vban-cmd
|
||||||
|
```
|
||||||
|
|
||||||
## `Use`
|
## `Use`
|
||||||
|
|
||||||
@@ -39,14 +41,14 @@ Load VBAN connection info from toml config. A valid `vban.toml` might look like
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[connection]
|
[connection]
|
||||||
ip = "gamepc.local"
|
host = "localhost"
|
||||||
port = 6980
|
port = 6980
|
||||||
streamname = "Command1"
|
streamname = "Command1"
|
||||||
```
|
```
|
||||||
|
|
||||||
It should be placed next to your `__main__.py` file.
|
It should be placed in \<user home directory\> / "Documents" / "Voicemeeter" / "configs"
|
||||||
|
|
||||||
Alternatively you may pass `ip`, `port`, `streamname` as keyword arguments.
|
Alternatively you may pass `host`, `port`, `streamname` as keyword arguments.
|
||||||
|
|
||||||
#### `__main__.py`
|
#### `__main__.py`
|
||||||
|
|
||||||
@@ -63,27 +65,27 @@ class ManyThings:
|
|||||||
self.vban = vban
|
self.vban = vban
|
||||||
|
|
||||||
def things(self):
|
def things(self):
|
||||||
self.vban.strip[0].label = "podmic"
|
self.vban.strip[0].label = 'podmic'
|
||||||
self.vban.strip[0].mute = True
|
self.vban.strip[0].mute = True
|
||||||
print(
|
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):
|
def other_things(self):
|
||||||
self.vban.bus[3].gain = -6.3
|
self.vban.bus[3].gain = -6.3
|
||||||
self.vban.bus[4].eq.on = True
|
self.vban.bus[4].eq = True
|
||||||
info = (
|
info = (
|
||||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||||
f"bus 4 eq has been set to {self.vban.bus[4].eq.on}",
|
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||||
)
|
)
|
||||||
print("\n".join(info))
|
print('\n'.join(info))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
KIND_ID = "banana"
|
KIND_ID = 'banana'
|
||||||
|
|
||||||
with vban_cmd.api(
|
with vban_cmd.api(
|
||||||
KIND_ID, ip="gamepc.local", port=6980, streamname="Command1"
|
KIND_ID, host='localhost', port=6980, streamname='Command1'
|
||||||
) as vban:
|
) as vban:
|
||||||
do = ManyThings(vban)
|
do = ManyThings(vban)
|
||||||
do.things()
|
do.things()
|
||||||
@@ -92,13 +94,14 @@ def main():
|
|||||||
# set many parameters at once
|
# set many parameters at once
|
||||||
vban.apply(
|
vban.apply(
|
||||||
{
|
{
|
||||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||||
"bus-2": {"mute": True, "eq": {"on": True}},
|
'bus-2': {'mute': True},
|
||||||
|
'vban-in-0': {'on': True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,6 +115,8 @@ Pass the kind of Voicemeeter as an argument. KIND_ID may be:
|
|||||||
- `banana`
|
- `banana`
|
||||||
- `potato`
|
- `potato`
|
||||||
|
|
||||||
|
A fourth kind `matrix` has been added, if you pass it as a KIND_ID you are expected to use the [{VbanCmd}.sendtext()](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#vbansendtextscript) method for sending text requests.
|
||||||
|
|
||||||
## `Available commands`
|
## `Available commands`
|
||||||
|
|
||||||
### Strip
|
### Strip
|
||||||
@@ -146,8 +151,8 @@ Set mute state as value for the app matching name.
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
vban.strip[5].appmute("Spotify", True)
|
vban.strip[5].appmute('Spotify', True)
|
||||||
vban.strip[5].appgain("Spotify", 0.5)
|
vban.strip[5].appgain('Spotify', 0.5)
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Strip.Comp
|
##### Strip.Comp
|
||||||
@@ -167,12 +172,10 @@ The following properties are available.
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
print(vm.strip[4].comp.knob)
|
print(vban.strip[4].comp.knob)
|
||||||
```
|
```
|
||||||
|
|
||||||
Strip Comp properties are defined as write only.
|
Strip Comp `knob` is defined for all versions, all other parameters potato only.
|
||||||
|
|
||||||
`knob` defined for all versions, all other parameters potato only.
|
|
||||||
|
|
||||||
##### Strip.Gate
|
##### Strip.Gate
|
||||||
|
|
||||||
@@ -189,12 +192,10 @@ The following properties are available.
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
vm.strip[2].gate.attack = 300.8
|
vban.strip[2].gate.attack = 300.8
|
||||||
```
|
```
|
||||||
|
|
||||||
Strip Gate properties are defined as write only, potato version only.
|
Strip Gate `knob` is defined for all versions, all other parameters potato only.
|
||||||
|
|
||||||
`knob` defined for all versions, all other parameters potato only.
|
|
||||||
|
|
||||||
##### Strip.Denoiser
|
##### Strip.Denoiser
|
||||||
|
|
||||||
@@ -211,7 +212,32 @@ The following properties are available.
|
|||||||
- `on`: boolean
|
- `on`: boolean
|
||||||
- `ab`: boolean
|
- `ab`: boolean
|
||||||
|
|
||||||
Strip EQ properties are defined as write only, potato version only.
|
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
|
##### Gainlayers
|
||||||
|
|
||||||
@@ -251,7 +277,6 @@ The following properties are available.
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
vban.bus[4].eq = true
|
|
||||||
print(vban.bus[0].label)
|
print(vban.bus[0].label)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -262,6 +287,10 @@ The following properties are available.
|
|||||||
- `on`: boolean
|
- `on`: boolean
|
||||||
- `ab`: boolean
|
- `ab`: boolean
|
||||||
|
|
||||||
|
```python
|
||||||
|
vban.bus[4].eq.on = true
|
||||||
|
```
|
||||||
|
|
||||||
##### Modes
|
##### Modes
|
||||||
|
|
||||||
The following properties are available.
|
The following properties are available.
|
||||||
@@ -320,6 +349,40 @@ vban.strip[0].fadeto(-10.3, 1000)
|
|||||||
vban.bus[3].fadeby(-5.6, 500)
|
vban.bus[3].fadeby(-5.6, 500)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Recorder
|
||||||
|
|
||||||
|
The following methods are available
|
||||||
|
|
||||||
|
- `play()`
|
||||||
|
- `stop()`
|
||||||
|
- `pause()`
|
||||||
|
- `record()`
|
||||||
|
- `ff()`
|
||||||
|
- `rew()`
|
||||||
|
- `load(filepath)`: raw string
|
||||||
|
- `goto(time_string)`: time string in format `hh:mm:ss`
|
||||||
|
|
||||||
|
The following properties are available
|
||||||
|
|
||||||
|
- `samplerate`: int, (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
|
||||||
|
- `bitresolution`: int, (8, 16, 24, 32)
|
||||||
|
- `channel`: int, from 1 to 8
|
||||||
|
- `kbps`: int, (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
|
||||||
|
- `gain`: float, from -60.0 to 12.0
|
||||||
|
|
||||||
|
example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
vban.recorder.play()
|
||||||
|
vban.recorder.stop()
|
||||||
|
|
||||||
|
# filepath as raw string
|
||||||
|
vban.recorder.load(r'C:\music\mytune.mp3')
|
||||||
|
|
||||||
|
# set the goto time to 1m 30s
|
||||||
|
vban.recorder.goto('00:01:30')
|
||||||
|
```
|
||||||
|
|
||||||
### Command
|
### Command
|
||||||
|
|
||||||
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
|
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
|
||||||
@@ -349,8 +412,10 @@ vban.command.showvbanchat = true
|
|||||||
```python
|
```python
|
||||||
vban.apply(
|
vban.apply(
|
||||||
{
|
{
|
||||||
"strip-0": {"A1": True, "B1": True, "gain": -6.0},
|
'strip-0': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||||
"bus-1": {"mute": True, "mode": "composite"},
|
'bus-1': {'mute': True, 'mode': 'composite'},
|
||||||
|
'bus-2': {'eq': {'on': True}},
|
||||||
|
'vban-in-0': {'on': True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -358,8 +423,8 @@ vban.apply(
|
|||||||
Or for each class you may do:
|
Or for each class you may do:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
vban.strip[0].apply(mute: true, gain: 3.2, A1: true)
|
vban.strip[0].apply({'mute': True, 'gain': 3.2, 'A1': True})
|
||||||
vban.bus[0].apply(A1: true)
|
vban.vban.outstream[0].apply({'on': True, 'name': 'streamname', 'bit': 24})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config Files
|
## Config Files
|
||||||
@@ -368,7 +433,7 @@ vban.bus[0].apply(A1: true)
|
|||||||
|
|
||||||
You may load config files in TOML format.
|
You may load config files in TOML format.
|
||||||
Three example configs have been included with the package. Remember to save
|
Three example configs have been included with the package. Remember to save
|
||||||
current settings before loading a user config. To set one you may do:
|
current settings before loading a user config. To load one you may do:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import vban_cmd
|
import vban_cmd
|
||||||
@@ -378,6 +443,27 @@ with vban_cmd.api('banana') as vban:
|
|||||||
|
|
||||||
will load a config file at configs/banana/example.toml for Voicemeeter Banana.
|
will load a config file at configs/banana/example.toml for Voicemeeter Banana.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### `config extends`
|
||||||
|
|
||||||
|
You may also load a config that extends another config with overrides or additional parameters.
|
||||||
|
|
||||||
|
You just need to define a key `extends` in the config TOML, that names the config to be extended.
|
||||||
|
|
||||||
|
Three example 'extender' configs are included with the repo. You may load them with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import vban_cmd
|
||||||
|
with vban_cmd.api('banana') as vm:
|
||||||
|
vm.apply_config('extender')
|
||||||
|
```
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
|
Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
|
||||||
@@ -386,11 +472,11 @@ example:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
import vban_cmd
|
import vban_cmd
|
||||||
# Listen for level updates
|
|
||||||
opts = {
|
opts = {
|
||||||
"ip": "<ip address>",
|
'host': '<ip address>',
|
||||||
"streamname": "Command1",
|
'streamname': 'Command1',
|
||||||
"port": 6980,
|
'port': 6980,
|
||||||
}
|
}
|
||||||
with vban_cmd.api('banana', ldirty=True, **opts) as vban:
|
with vban_cmd.api('banana', ldirty=True, **opts) as vban:
|
||||||
...
|
...
|
||||||
@@ -443,7 +529,7 @@ The following methods are available:
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
vban.event.remove(["pdirty", "ldirty"])
|
vban.event.remove(['pdirty', 'ldirty'])
|
||||||
|
|
||||||
# get a list of currently subscribed
|
# get a list of currently subscribed
|
||||||
print(vban.event.get())
|
print(vban.event.get())
|
||||||
@@ -455,11 +541,17 @@ print(vban.event.get())
|
|||||||
|
|
||||||
You may pass the following optional keyword arguments:
|
You may pass the following optional keyword arguments:
|
||||||
|
|
||||||
- `ip`: str, ip or hostname of remote machine
|
- `host`: str='localhost', ip or hostname of remote machine
|
||||||
- `streamname`: str, name of the stream to connect to.
|
|
||||||
- `port`: int=6980, vban udp port of remote machine.
|
- `port`: int=6980, vban udp port of remote machine.
|
||||||
- `pdirty`: parameter updates
|
- `streamname`: str='Command1', name of the stream to connect to.
|
||||||
- `ldirty`: level updates
|
- `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`
|
#### `vban.pdirty`
|
||||||
|
|
||||||
@@ -474,24 +566,46 @@ True iff a level value has been changed.
|
|||||||
Sends a script block as a string request, for example:
|
Sends a script block as a string request, for example:
|
||||||
|
|
||||||
```python
|
```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 `VbanRtPacket`. 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.VBANCMDError`: 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
|
## Resources
|
||||||
|
|
||||||
|
|||||||
21
__main__.py
21
__main__.py
@@ -6,27 +6,27 @@ class ManyThings:
|
|||||||
self.vban = vban
|
self.vban = vban
|
||||||
|
|
||||||
def things(self):
|
def things(self):
|
||||||
self.vban.strip[0].label = "podmic"
|
self.vban.strip[0].label = 'podmic'
|
||||||
self.vban.strip[0].mute = True
|
self.vban.strip[0].mute = True
|
||||||
print(
|
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):
|
def other_things(self):
|
||||||
self.vban.bus[3].gain = -6.3
|
self.vban.bus[3].gain = -6.3
|
||||||
self.vban.bus[4].eq = True
|
self.vban.bus[4].eq = True
|
||||||
info = (
|
info = (
|
||||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||||
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
|
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||||
)
|
)
|
||||||
print("\n".join(info))
|
print('\n'.join(info))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
kind_id = "banana"
|
KIND_ID = 'banana'
|
||||||
|
|
||||||
with vban_cmd.api(
|
with vban_cmd.api(
|
||||||
kind_id, ip="gamepc.local", port=6980, streamname="Command1"
|
KIND_ID, ip='gamepc.local', port=6980, streamname='Command1'
|
||||||
) as vban:
|
) as vban:
|
||||||
do = ManyThings(vban)
|
do = ManyThings(vban)
|
||||||
do.things()
|
do.things()
|
||||||
@@ -35,11 +35,12 @@ def main():
|
|||||||
# set many parameters at once
|
# set many parameters at once
|
||||||
vban.apply(
|
vban.apply(
|
||||||
{
|
{
|
||||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||||
"bus-2": {"mute": True},
|
'bus-2': {'mute': True},
|
||||||
|
'vban-in-0': {'on': True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
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"
|
||||||
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"
|
||||||
@@ -1,34 +1,38 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
|
||||||
import vban_cmd
|
import vban_cmd
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
|
|
||||||
|
|
||||||
class App(tk.Tk):
|
class App(tk.Tk):
|
||||||
|
INDEX = 3
|
||||||
|
|
||||||
def __init__(self, vban):
|
def __init__(self, vban):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.vban = vban
|
self.vban = vban
|
||||||
self.title(f"{vban} - version {vban.version}")
|
self.title(f'{vban} - version {vban.version}')
|
||||||
self.vban.observer.add(self.on_ldirty)
|
self.vban.observer.add(self.on_ldirty)
|
||||||
|
|
||||||
# create widget variables
|
# create widget variables
|
||||||
self.button_var = tk.BooleanVar(value=vban.strip[3].mute)
|
self.button_var = tk.BooleanVar(value=vban.strip[self.INDEX].mute)
|
||||||
self.slider_var = tk.DoubleVar(value=vban.strip[3].gain)
|
self.slider_var = tk.DoubleVar(value=vban.strip[self.INDEX].gain)
|
||||||
self.meter_var = tk.DoubleVar(value=self._get_level())
|
self.meter_var = tk.DoubleVar(value=self._get_level())
|
||||||
self.gainlabel_var = tk.StringVar(value=self.slider_var.get())
|
self.gainlabel_var = tk.StringVar(value=self.slider_var.get())
|
||||||
|
|
||||||
# initialize style table
|
# initialize style table
|
||||||
self.style = ttk.Style()
|
self.style = ttk.Style()
|
||||||
self.style.theme_use("clam")
|
self.style.theme_use('clam')
|
||||||
self.style.configure(
|
self.style.configure(
|
||||||
"Mute.TButton", foreground="#cd5c5c" if vban.strip[3].mute else "#5a5a5a"
|
'Mute.TButton',
|
||||||
|
foreground='#cd5c5c' if vban.strip[self.INDEX].mute else '#5a5a5a',
|
||||||
)
|
)
|
||||||
|
|
||||||
# create labelframe and grid it onto the mainframe
|
# create labelframe and grid it onto the mainframe
|
||||||
self.labelframe = tk.LabelFrame(text=self.vban.strip[3].label)
|
self.labelframe = tk.LabelFrame(text=self.vban.strip[self.INDEX].label)
|
||||||
self.labelframe.grid(padx=1)
|
self.labelframe.grid(padx=1)
|
||||||
|
|
||||||
# create slider and grid it onto the labelframe
|
# create slider and grid it onto the labelframe
|
||||||
@@ -36,7 +40,7 @@ class App(tk.Tk):
|
|||||||
self.labelframe,
|
self.labelframe,
|
||||||
from_=12,
|
from_=12,
|
||||||
to_=-60,
|
to_=-60,
|
||||||
orient="vertical",
|
orient='vertical',
|
||||||
variable=self.slider_var,
|
variable=self.slider_var,
|
||||||
command=lambda arg: self.on_slider_move(arg),
|
command=lambda arg: self.on_slider_move(arg),
|
||||||
)
|
)
|
||||||
@@ -44,14 +48,15 @@ class App(tk.Tk):
|
|||||||
column=0,
|
column=0,
|
||||||
row=0,
|
row=0,
|
||||||
)
|
)
|
||||||
|
slider.bind('<Double-Button-1>', self.on_button_double_click)
|
||||||
|
|
||||||
# create level meter and grid it onto the labelframe
|
# create level meter and grid it onto the labelframe
|
||||||
level_meter = ttk.Progressbar(
|
level_meter = ttk.Progressbar(
|
||||||
self.labelframe,
|
self.labelframe,
|
||||||
orient="vertical",
|
orient='vertical',
|
||||||
variable=self.meter_var,
|
variable=self.meter_var,
|
||||||
maximum=72,
|
maximum=72,
|
||||||
mode="determinate",
|
mode='determinate',
|
||||||
)
|
)
|
||||||
level_meter.grid(column=1, row=0)
|
level_meter.grid(column=1, row=0)
|
||||||
|
|
||||||
@@ -62,8 +67,8 @@ class App(tk.Tk):
|
|||||||
# create button and grid it onto the labelframe
|
# create button and grid it onto the labelframe
|
||||||
button = ttk.Button(
|
button = ttk.Button(
|
||||||
self.labelframe,
|
self.labelframe,
|
||||||
text="Mute",
|
text='Mute',
|
||||||
style="Mute.TButton",
|
style='Mute.TButton',
|
||||||
command=lambda: self.on_button_press(),
|
command=lambda: self.on_button_press(),
|
||||||
)
|
)
|
||||||
button.grid(column=0, row=2, columnspan=2, padx=1, pady=2)
|
button.grid(column=0, row=2, columnspan=2, padx=1, pady=2)
|
||||||
@@ -72,18 +77,23 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
def on_slider_move(self, *args):
|
def on_slider_move(self, *args):
|
||||||
val = round(self.slider_var.get(), 1)
|
val = round(self.slider_var.get(), 1)
|
||||||
self.vban.strip[3].gain = val
|
self.vban.strip[self.INDEX].gain = val
|
||||||
self.gainlabel_var.set(val)
|
self.gainlabel_var.set(val)
|
||||||
|
|
||||||
def on_button_press(self):
|
def on_button_press(self):
|
||||||
self.button_var.set(not self.button_var.get())
|
self.button_var.set(not self.button_var.get())
|
||||||
self.vban.strip[3].mute = self.button_var.get()
|
self.vban.strip[self.INDEX].mute = self.button_var.get()
|
||||||
self.style.configure(
|
self.style.configure(
|
||||||
"Mute.TButton", foreground="#cd5c5c" if self.button_var.get() else "#5a5a5a"
|
'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):
|
def _get_level(self):
|
||||||
val = max(self.vban.strip[3].levels.prefader)
|
val = max(self.vban.strip[self.INDEX].levels.prefader)
|
||||||
return 0 if self.button_var.get() else 72 + val - 12 + self.slider_var.get()
|
return 0 if self.button_var.get() else 72 + val - 12 + self.slider_var.get()
|
||||||
|
|
||||||
def on_ldirty(self):
|
def on_ldirty(self):
|
||||||
@@ -91,10 +101,17 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
with vban_cmd.api("banana", ldirty=True) as vban:
|
KIND_ID = 'banana'
|
||||||
|
conn = {
|
||||||
|
'ip': os.environ.get('VBANCMD_IP', '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 = App(vban)
|
||||||
app.mainloop()
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import time
|
import os
|
||||||
|
import threading
|
||||||
from logging import config
|
from logging import config
|
||||||
|
|
||||||
import obsws_python as obsws
|
import obsws_python as obsws
|
||||||
@@ -7,85 +8,103 @@ import vban_cmd
|
|||||||
|
|
||||||
config.dictConfig(
|
config.dictConfig(
|
||||||
{
|
{
|
||||||
"version": 1,
|
'version': 1,
|
||||||
"formatters": {
|
'formatters': {
|
||||||
"standard": {
|
'standard': {
|
||||||
"format": "%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s"
|
'format': '%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"handlers": {
|
'handlers': {
|
||||||
"stream": {
|
'stream': {
|
||||||
"level": "DEBUG",
|
'level': 'DEBUG',
|
||||||
"class": "logging.StreamHandler",
|
'class': 'logging.StreamHandler',
|
||||||
"formatter": "standard",
|
'formatter': 'standard',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loggers": {"vban_cmd.iremote": {"handlers": ["stream"], "level": "DEBUG"}},
|
'loggers': {
|
||||||
|
'vban_cmd.iremote': {
|
||||||
|
'handlers': ['stream'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'root': {'handlers': ['stream'], 'level': 'WARNING'},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Observer:
|
class Observer:
|
||||||
def __init__(self, vban):
|
def __init__(self, vban, stop_event):
|
||||||
self.vban = vban
|
self._vban = vban
|
||||||
self.client = obsws.EventClient()
|
self._stop_event = stop_event
|
||||||
self.client.callback.register(
|
self._client = obsws.EventClient()
|
||||||
|
self._client.callback.register(
|
||||||
(
|
(
|
||||||
self.on_current_program_scene_changed,
|
self.on_current_program_scene_changed,
|
||||||
self.on_exit_started,
|
self.on_exit_started,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.is_running = True
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||||
|
self._client.disconnect()
|
||||||
|
|
||||||
def on_start(self):
|
def on_start(self):
|
||||||
self.vban.strip[0].mute = True
|
self._vban.strip[0].mute = True
|
||||||
self.vban.strip[1].B1 = True
|
self._vban.strip[1].B1 = True
|
||||||
self.vban.strip[2].B2 = True
|
self._vban.strip[2].B2 = True
|
||||||
|
|
||||||
def on_brb(self):
|
def on_brb(self):
|
||||||
self.vban.strip[7].fadeto(0, 500)
|
self._vban.strip[7].fadeto(0, 500)
|
||||||
self.vban.bus[0].mute = True
|
self._vban.bus[0].mute = True
|
||||||
|
|
||||||
def on_end(self):
|
def on_end(self):
|
||||||
self.vban.apply(
|
self._vban.apply(
|
||||||
{
|
{
|
||||||
"strip-0": {"mute": True},
|
'strip-0': {'mute': True},
|
||||||
"strip-1": {"mute": True, "B1": False},
|
'strip-1': {'mute': True, 'B1': False},
|
||||||
"strip-2": {"mute": True, "B1": False},
|
'strip-2': {'mute': True, 'B1': False},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_live(self):
|
def on_live(self):
|
||||||
self.vban.strip[0].mute = False
|
self._vban.strip[0].mute = False
|
||||||
self.vban.strip[7].fadeto(-6, 500)
|
self._vban.strip[7].fadeto(-6, 500)
|
||||||
self.vban.strip[7].A3 = True
|
self._vban.strip[7].A3 = True
|
||||||
|
|
||||||
def on_current_program_scene_changed(self, data):
|
def on_current_program_scene_changed(self, data):
|
||||||
def fget(scene):
|
|
||||||
run = {
|
|
||||||
"START": self.on_start,
|
|
||||||
"BRB": self.on_brb,
|
|
||||||
"END": self.on_end,
|
|
||||||
"LIVE": self.on_live,
|
|
||||||
}
|
|
||||||
return run.get(scene)
|
|
||||||
|
|
||||||
scene = data.scene_name
|
scene = data.scene_name
|
||||||
print(f"Switched to scene {scene}")
|
print(f'Switched to scene {scene}')
|
||||||
if fn := fget(scene):
|
match scene:
|
||||||
fn()
|
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, _):
|
def on_exit_started(self, _):
|
||||||
self.client.unsubscribe()
|
self._stop_event.set()
|
||||||
self.is_running = False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
with vban_cmd.api("potato") as vban:
|
KIND_ID = 'potato'
|
||||||
observer = Observer(vban)
|
conn = {
|
||||||
while observer.is_running:
|
'ip': os.environ.get('VBANCMD_IP', 'localhost'),
|
||||||
time.sleep(0.1)
|
'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__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="obs",
|
name='obs',
|
||||||
description="OBS Example",
|
description='OBS Example',
|
||||||
install_requires=["obsws-python"],
|
install_requires=['obsws-python'],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import vban_cmd
|
import vban_cmd
|
||||||
|
|
||||||
@@ -13,23 +14,28 @@ class App:
|
|||||||
|
|
||||||
# define an 'on_update' callback function to receive event updates
|
# define an 'on_update' callback function to receive event updates
|
||||||
def on_update(self, event):
|
def on_update(self, event):
|
||||||
if event == "pdirty":
|
if event == 'pdirty':
|
||||||
print("pdirty!")
|
print('pdirty!')
|
||||||
elif event == "ldirty":
|
elif event == 'ldirty':
|
||||||
for bus in self.vban.bus:
|
for bus in self.vban.bus:
|
||||||
if bus.levels.isdirty:
|
if bus.levels.isdirty:
|
||||||
print(bus, bus.levels.all)
|
print(bus, bus.levels.all)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
KIND_ID = "banana"
|
KIND_ID = 'banana'
|
||||||
|
conn = {
|
||||||
|
'ip': os.environ.get('VBANCMD_IP', '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) as vban:
|
with vban_cmd.api(KIND_ID, pdirty=True, ldirty=True, **conn) as vban:
|
||||||
App(vban)
|
App(vban)
|
||||||
|
|
||||||
while cmd := input("Press <Enter> to exit\n"):
|
while _ := input('Press <Enter> to exit\n'):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
429
poetry.lock
generated
429
poetry.lock
generated
@@ -1,304 +1,363 @@
|
|||||||
[[package]]
|
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
|
||||||
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.8.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"
|
|
||||||
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
|
|
||||||
|
|
||||||
[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]]
|
[[package]]
|
||||||
name = "cachetools"
|
name = "cachetools"
|
||||||
version = "5.3.1"
|
version = "5.5.0"
|
||||||
description = "Extensible memoizing collections and decorators"
|
description = "Extensible memoizing collections and decorators"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
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]]
|
[[package]]
|
||||||
name = "chardet"
|
name = "chardet"
|
||||||
version = "5.1.0"
|
version = "5.2.0"
|
||||||
description = "Universal encoding detector for Python 3"
|
description = "Universal encoding detector for Python 3"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
[[package]]
|
files = [
|
||||||
name = "click"
|
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
|
||||||
version = "8.1.3"
|
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
|
||||||
description = "Composable command line interface toolkit"
|
]
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
description = "Cross-platform colored terminal text."
|
description = "Cross-platform colored terminal text."
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
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]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.3.6"
|
version = "0.3.9"
|
||||||
description = "Distribution utilities"
|
description = "Distribution utilities"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.12.2"
|
version = "3.16.1"
|
||||||
description = "A platform independent file lock."
|
description = "A platform independent file lock."
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
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]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||||
testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)"]
|
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]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "1.1.1"
|
version = "2.0.0"
|
||||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
description = "brain-dead simple config-ini parsing"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
[[package]]
|
files = [
|
||||||
name = "isort"
|
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||||
version = "5.10.1"
|
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||||
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 = "*"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "23.1"
|
version = "24.2"
|
||||||
description = "Core utilities for Python packages"
|
description = "Core utilities for Python packages"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
[[package]]
|
files = [
|
||||||
name = "pathspec"
|
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||||
version = "0.10.1"
|
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
|
||||||
description = "Utility library for gitignore style pattern matching of file paths."
|
]
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "3.7.0"
|
version = "4.3.6"
|
||||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||||
category = "dev"
|
|
||||||
optional = false
|
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]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
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-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.1)"]
|
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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.0.0"
|
version = "1.5.0"
|
||||||
description = "plugin and hook calling mechanisms for python"
|
description = "plugin and hook calling mechanisms for python"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
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]
|
[package.extras]
|
||||||
dev = ["pre-commit", "tox"]
|
dev = ["pre-commit", "tox"]
|
||||||
testing = ["pytest", "pytest-benchmark"]
|
testing = ["pytest", "pytest-benchmark"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "py"
|
name = "pyenv-inspect"
|
||||||
version = "1.11.0"
|
version = "0.4.0"
|
||||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
description = "An auxiliary library for the virtualenv-pyenv and tox-pyenv-redux plugins"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
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]]
|
[[package]]
|
||||||
name = "pyproject-api"
|
name = "pyproject-api"
|
||||||
version = "1.5.2"
|
version = "1.8.0"
|
||||||
description = "API to interact with the python pyproject.toml based projects"
|
description = "API to interact with the python pyproject.toml based projects"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
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]
|
[package.dependencies]
|
||||||
packaging = ">=23.1"
|
packaging = ">=24.1"
|
||||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||||
testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "wheel (>=0.40)"]
|
testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "7.1.3"
|
version = "8.3.4"
|
||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
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]
|
[package.dependencies]
|
||||||
attrs = ">=19.2.0"
|
|
||||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||||
|
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||||
iniconfig = "*"
|
iniconfig = "*"
|
||||||
packaging = "*"
|
packaging = "*"
|
||||||
pluggy = ">=0.12,<2.0"
|
pluggy = ">=1.5,<2"
|
||||||
py = ">=1.8.2"
|
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||||
tomli = ">=1.0.0"
|
|
||||||
|
|
||||||
[package.extras]
|
[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]]
|
[[package]]
|
||||||
name = "pytest-randomly"
|
name = "pytest-randomly"
|
||||||
version = "3.12.0"
|
version = "3.16.0"
|
||||||
description = "Pytest plugin to randomly order tests and control random.seed."
|
description = "Pytest plugin to randomly order tests and control random.seed."
|
||||||
category = "dev"
|
|
||||||
optional = false
|
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]
|
[package.dependencies]
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-repeat"
|
name = "ruff"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
description = "pytest plugin for repeating tests"
|
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
[package.dependencies]
|
files = [
|
||||||
pytest = ">=3.6"
|
{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]]
|
[[package]]
|
||||||
name = "tomli"
|
name = "tomli"
|
||||||
version = "2.0.1"
|
version = "2.2.1"
|
||||||
description = "A lil' TOML parser"
|
description = "A lil' TOML parser"
|
||||||
category = "main"
|
|
||||||
optional = false
|
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]]
|
[[package]]
|
||||||
name = "tox"
|
name = "tox"
|
||||||
version = "4.6.3"
|
version = "4.23.2"
|
||||||
description = "tox is a generic virtualenv management and test command line tool"
|
description = "tox is a generic virtualenv management and test command line tool"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
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]
|
[package.dependencies]
|
||||||
cachetools = ">=5.3.1"
|
cachetools = ">=5.5"
|
||||||
chardet = ">=5.1"
|
chardet = ">=5.2"
|
||||||
colorama = ">=0.4.6"
|
colorama = ">=0.4.6"
|
||||||
filelock = ">=3.12.2"
|
filelock = ">=3.16.1"
|
||||||
packaging = ">=23.1"
|
packaging = ">=24.1"
|
||||||
platformdirs = ">=3.5.3"
|
platformdirs = ">=4.3.6"
|
||||||
pluggy = ">=1"
|
pluggy = ">=1.5"
|
||||||
pyproject-api = ">=1.5.2"
|
pyproject-api = ">=1.8"
|
||||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||||
virtualenv = ">=20.23.1"
|
typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
|
||||||
|
virtualenv = ">=20.26.6"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.5.20)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
|
||||||
testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "pytest (>=7.3.2)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"]
|
|
||||||
|
[[package]]
|
||||||
|
name = "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]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.23.1"
|
version = "20.29.0"
|
||||||
description = "Virtual Python Environment builder"
|
description = "Virtual Python Environment builder"
|
||||||
category = "dev"
|
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
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]
|
[package.dependencies]
|
||||||
distlib = ">=0.3.6,<1"
|
distlib = ">=0.3.7,<1"
|
||||||
filelock = ">=3.12,<4"
|
filelock = ">=3.12.2,<4"
|
||||||
platformdirs = ">=3.5.1,<4"
|
platformdirs = ">=3.9.1,<5"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
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-enable-subprocess (>=1)", "coverage (>=7.2.7)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"]
|
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]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.10"
|
python-versions = ">=3.10"
|
||||||
content-hash = "5d0edd070ea010edb4e2ade88dc37324b8b4b04f22db78e49db161185365849b"
|
content-hash = "13fc9d0eb15d5fc09b54c1c8cd8f528b260259e97ee6813b50ab4724c35d6677"
|
||||||
|
|
||||||
[metadata.files]
|
|
||||||
attrs = []
|
|
||||||
black = []
|
|
||||||
cachetools = []
|
|
||||||
chardet = []
|
|
||||||
click = []
|
|
||||||
colorama = []
|
|
||||||
distlib = []
|
|
||||||
filelock = []
|
|
||||||
iniconfig = []
|
|
||||||
isort = []
|
|
||||||
mypy-extensions = []
|
|
||||||
packaging = []
|
|
||||||
pathspec = []
|
|
||||||
platformdirs = []
|
|
||||||
pluggy = []
|
|
||||||
py = []
|
|
||||||
pyproject-api = []
|
|
||||||
pytest = []
|
|
||||||
pytest-randomly = []
|
|
||||||
pytest-repeat = []
|
|
||||||
tomli = []
|
|
||||||
tox = []
|
|
||||||
virtualenv = []
|
|
||||||
|
|||||||
141
pyproject.toml
141
pyproject.toml
@@ -1,43 +1,138 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "vban-cmd"
|
name = "vban-cmd"
|
||||||
version = "2.0.0"
|
version = "2.9.7"
|
||||||
description = "Python interface for the VBAN RT Packet Service (Sendtext)"
|
description = "Python interface for the VBAN RT Packet Service (Sendtext)"
|
||||||
authors = ["onyx-and-iris <code@onyxandiris.online>"]
|
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
|
||||||
license = "MIT"
|
license = { text = "MIT" }
|
||||||
readme = "README.md"
|
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]
|
[tool.poetry.requires-plugins]
|
||||||
python = "^3.10"
|
poethepoet = ">=0.42.0"
|
||||||
tomli = { version = "^2.0.1", python = "<3.11" }
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
[tool.poetry.dev-dependencies]
|
pytest = "^8.3.4"
|
||||||
pytest = "^7.1.2"
|
pytest-randomly = "^3.16.0"
|
||||||
pytest-randomly = "^3.12.0"
|
ruff = "^0.9.2"
|
||||||
pytest-repeat = "^0.9.1"
|
tox = "^4.23.2"
|
||||||
black = "^22.3.0"
|
virtualenv-pyenv = "^0.5.0"
|
||||||
isort = "^5.10.1"
|
|
||||||
tox = "^4.6.3"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poe]
|
||||||
gui = "scripts:ex_gui"
|
envfile = ".env"
|
||||||
obs = "scripts:ex_obs"
|
|
||||||
observer = "scripts:ex_observer"
|
[tool.poe.tasks]
|
||||||
test = "scripts:test"
|
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]
|
[tool.tox]
|
||||||
legacy_tox_ini = """
|
legacy_tox_ini = """
|
||||||
[tox]
|
[tox]
|
||||||
envlist = py310,py311
|
envlist = py310,py311,py312,py313
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
|
passenv = *
|
||||||
|
setenv = VIRTUALENV_DISCOVERY=pyenv
|
||||||
allowlist_externals = poetry
|
allowlist_externals = poetry
|
||||||
commands =
|
commands =
|
||||||
poetry install -v
|
poetry install -v
|
||||||
poetry run pytest tests/
|
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"]
|
||||||
|
|||||||
30
scripts.py
30
scripts.py
@@ -1,21 +1,35 @@
|
|||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def ex_gui():
|
def ex_gui():
|
||||||
path = Path.cwd() / "examples" / "gui" / "."
|
scriptpath = Path.cwd() / 'examples' / 'gui' / '.'
|
||||||
subprocess.run(["py", str(path)])
|
subprocess.run([sys.executable, str(scriptpath)])
|
||||||
|
|
||||||
|
|
||||||
def ex_obs():
|
def ex_obs():
|
||||||
path = Path.cwd() / "examples" / "obs" / "."
|
subprocess.run(['tox', 'r', '-e', 'obs'])
|
||||||
subprocess.run(["py", str(path)])
|
|
||||||
|
|
||||||
|
|
||||||
def ex_observer():
|
def ex_observer():
|
||||||
path = Path.cwd() / "examples" / "observer" / "."
|
scriptpath = Path.cwd() / 'examples' / 'observer' / '.'
|
||||||
subprocess.run(["py", str(path)])
|
subprocess.run([sys.executable, str(scriptpath)])
|
||||||
|
|
||||||
|
|
||||||
def test():
|
def test_basic():
|
||||||
subprocess.run(["tox"])
|
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,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -6,14 +7,13 @@ import vban_cmd
|
|||||||
from vban_cmd.kinds import KindId
|
from vban_cmd.kinds import KindId
|
||||||
from vban_cmd.kinds import request_kind_map as kindmap
|
from vban_cmd.kinds import request_kind_map as kindmap
|
||||||
|
|
||||||
# let's keep things random
|
# get KIND from environment, if not set default to potato
|
||||||
KIND_ID = random.choice(tuple(kind_id.name.lower() for kind_id in KindId))
|
KIND_ID = os.environ.get('KIND', 'potato')
|
||||||
|
|
||||||
opts = {
|
opts = {
|
||||||
"ip": "testing.local",
|
'host': os.getenv('VBANCMD_HOST', 'localhost'),
|
||||||
"streamname": "testing",
|
'streamname': os.getenv('VBANCMD_STREAMNAME', 'Command1'),
|
||||||
"port": 6990,
|
'port': int(os.getenv('VBANCMD_PORT', 6980)),
|
||||||
"bps": 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vban = vban_cmd.api(KIND_ID, **opts)
|
vban = vban_cmd.api(KIND_ID, **opts)
|
||||||
@@ -39,7 +39,7 @@ data = Data()
|
|||||||
|
|
||||||
|
|
||||||
def setup_module():
|
def setup_module():
|
||||||
print(f"\nRunning tests for kind [{data.name}]\n", file=sys.stdout)
|
print(f'\nRunning tests for kind [{data.name}]\n', file=sys.stdout)
|
||||||
vban.login()
|
vban.login()
|
||||||
vban.command.reset()
|
vban.command.reset()
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
def pytest_addoption(parser):
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--run-slow",
|
'--run-slow',
|
||||||
action="store_true",
|
action='store_true',
|
||||||
default=False,
|
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
|
$line | Tee-Object -FilePath $coverage -Append
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Output "$(Get-TimeStamp)" | Out-file $coverage -Append
|
Write-Output "$(Get-TimeStamp)" | Out-File $coverage -Append
|
||||||
|
|
||||||
Invoke-Expression "genbadge tests -t 90 -i ./tests/.coverage.xml -o ./tests/$kind.svg"
|
Invoke-Expression "genbadge tests -t 90 -i ./tests/.coverage.xml -o ./tests/$kind.svg"
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,10 @@ Function Get-TimeStamp {
|
|||||||
if ($MyInvocation.InvocationName -ne ".") {
|
if ($MyInvocation.InvocationName -ne ".") {
|
||||||
Invoke-Expression ".\.venv\Scripts\Activate.ps1"
|
Invoke-Expression ".\.venv\Scripts\Activate.ps1"
|
||||||
|
|
||||||
RunTests
|
@("potato") | ForEach-Object {
|
||||||
|
$env:KIND = $_
|
||||||
|
RunTests
|
||||||
|
}
|
||||||
|
|
||||||
Invoke-Expression "deactivate"
|
Invoke-Expression "deactivate"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests import data, vban
|
from tests import data, vban
|
||||||
@@ -10,18 +12,27 @@ class TestSetAndGetBoolHigher:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_class(cls):
|
def setup_class(cls):
|
||||||
vban.apply_config("example")
|
vban.apply_config('example')
|
||||||
|
time.sleep(0.1)
|
||||||
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
|
|
||||||
|
|
||||||
def test_it_tests_config_bool(self):
|
|
||||||
assert vban.strip[0].A1 == True
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
"not config.getoption('--run-slow')",
|
"not config.getoption('--run-slow')",
|
||||||
reason="Only run when --run-slow is given",
|
reason='Only run when --run-slow is given',
|
||||||
|
)
|
||||||
|
def test_it_tests_config_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):
|
def test_it_tests_config_busmode(self):
|
||||||
assert vban.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)
|
||||||
@@ -7,37 +7,49 @@ class TestRemoteFactories:
|
|||||||
__test__ = True
|
__test__ = True
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "basic",
|
data.name != 'basic',
|
||||||
reason="Skip test if kind is not basic",
|
reason='Skip test if kind is not basic',
|
||||||
)
|
)
|
||||||
def test_it_tests_remote_attrs_for_basic(self):
|
def test_it_tests_remote_attrs_for_basic(self):
|
||||||
assert hasattr(vban, "strip")
|
assert hasattr(vban, 'strip')
|
||||||
assert hasattr(vban, "bus")
|
assert hasattr(vban, 'bus')
|
||||||
assert hasattr(vban, "command")
|
assert hasattr(vban, 'command')
|
||||||
|
assert hasattr(vban, 'button')
|
||||||
|
assert hasattr(vban, 'vban')
|
||||||
|
|
||||||
assert len(vban.strip) == 3
|
assert len(vban.strip) == 3
|
||||||
assert len(vban.bus) == 2
|
assert len(vban.bus) == 2
|
||||||
|
assert len(vban.button) == 80
|
||||||
|
assert len(vban.vban.instream) == 6 and len(vban.vban.outstream) == 5
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "banana",
|
data.name != 'banana',
|
||||||
reason="Skip test if kind is not basic",
|
reason='Skip test if kind is not basic',
|
||||||
)
|
)
|
||||||
def test_it_tests_remote_attrs_for_banana(self):
|
def test_it_tests_remote_attrs_for_banana(self):
|
||||||
assert hasattr(vban, "strip")
|
assert hasattr(vban, 'strip')
|
||||||
assert hasattr(vban, "bus")
|
assert hasattr(vban, 'bus')
|
||||||
assert hasattr(vban, "command")
|
assert hasattr(vban, 'command')
|
||||||
|
assert hasattr(vban, 'button')
|
||||||
|
assert hasattr(vban, 'vban')
|
||||||
|
|
||||||
assert len(vban.strip) == 5
|
assert len(vban.strip) == 5
|
||||||
assert len(vban.bus) == 5
|
assert len(vban.bus) == 5
|
||||||
|
assert len(vban.button) == 80
|
||||||
|
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "potato",
|
data.name != 'potato',
|
||||||
reason="Skip test if kind is not basic",
|
reason='Skip test if kind is not basic',
|
||||||
)
|
)
|
||||||
def test_it_tests_remote_attrs_for_potato(self):
|
def test_it_tests_remote_attrs_for_potato(self):
|
||||||
assert hasattr(vban, "strip")
|
assert hasattr(vban, 'strip')
|
||||||
assert hasattr(vban, "bus")
|
assert hasattr(vban, 'bus')
|
||||||
assert hasattr(vban, "command")
|
assert hasattr(vban, 'command')
|
||||||
|
assert hasattr(vban, 'button')
|
||||||
|
assert hasattr(vban, 'vban')
|
||||||
|
|
||||||
assert len(vban.strip) == 8
|
assert len(vban.strip) == 8
|
||||||
assert len(vban.bus) == 8
|
assert len(vban.bus) == 8
|
||||||
|
assert len(vban.button) == 80
|
||||||
|
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import pytest
|
|||||||
from tests import data, vban
|
from tests import data, vban
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [False, True])
|
@pytest.mark.parametrize('value', [False, True])
|
||||||
class TestSetAndGetBoolHigher:
|
class TestSetAndGetBoolHigher:
|
||||||
__test__ = True
|
__test__ = True
|
||||||
|
|
||||||
"""strip tests, physical and virtual"""
|
"""strip tests, physical and virtual"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,param",
|
'index,param',
|
||||||
[
|
[
|
||||||
(data.phys_in, "mute"),
|
(data.phys_in, 'mute'),
|
||||||
(data.virt_in, "solo"),
|
(data.virt_in, 'solo'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||||
@@ -21,13 +21,13 @@ class TestSetAndGetBoolHigher:
|
|||||||
assert getattr(vban.strip[index], param) == value
|
assert getattr(vban.strip[index], param) == value
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name == "banana",
|
data.name == 'banana',
|
||||||
reason="Only test if logged into Basic or Potato version",
|
reason='Only test if logged into Basic or Potato version',
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@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):
|
def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value):
|
||||||
@@ -37,10 +37,9 @@ class TestSetAndGetBoolHigher:
|
|||||||
""" bus tests, physical and virtual """
|
""" bus tests, physical and virtual """
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,param",
|
'index,param',
|
||||||
[
|
[
|
||||||
(data.phys_out, "mute"),
|
(data.phys_out, 'mute'),
|
||||||
(data.virt_out, "sel"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
|
def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
|
||||||
@@ -51,17 +50,17 @@ class TestSetAndGetBoolHigher:
|
|||||||
""" bus modes tests, physical and virtual """
|
""" bus modes tests, physical and virtual """
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,param",
|
'index,param',
|
||||||
[
|
[
|
||||||
(data.phys_out, "normal"),
|
(data.phys_out, 'normal'),
|
||||||
(data.phys_out, "amix"),
|
(data.phys_out, 'amix'),
|
||||||
(data.phys_out, "rearonly"),
|
(data.phys_out, 'rearonly'),
|
||||||
(data.virt_out, "normal"),
|
(data.virt_out, 'normal'),
|
||||||
(data.virt_out, "upmix41"),
|
(data.virt_out, 'upmix41'),
|
||||||
(data.virt_out, "composite"),
|
(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
|
# here it only makes sense to set/get bus modes as True
|
||||||
if not value:
|
if not value:
|
||||||
value = True
|
value = True
|
||||||
@@ -71,8 +70,8 @@ class TestSetAndGetBoolHigher:
|
|||||||
""" command tests """
|
""" command tests """
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"param",
|
'param',
|
||||||
[("lock")],
|
[('lock')],
|
||||||
)
|
)
|
||||||
def test_it_sets_command_bool_params(self, param, value):
|
def test_it_sets_command_bool_params(self, param, value):
|
||||||
setattr(vban.command, param, value)
|
setattr(vban.command, param, value)
|
||||||
@@ -86,10 +85,10 @@ class TestSetAndGetIntHigher:
|
|||||||
"""strip tests, physical and virtual"""
|
"""strip tests, physical and virtual"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,param,value",
|
'index,param,value',
|
||||||
[
|
[
|
||||||
(data.virt_in, "k", 0),
|
(data.virt_in, 'k', 0),
|
||||||
(data.virt_in, "k", 4),
|
(data.virt_in, 'k', 4),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||||
@@ -103,12 +102,12 @@ class TestSetAndGetFloatHigher:
|
|||||||
"""strip tests, physical and virtual"""
|
"""strip tests, physical and virtual"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,param,value",
|
'index,param,value',
|
||||||
[
|
[
|
||||||
(data.phys_in, "gain", -3.6),
|
(data.phys_in, 'gain', -3.6),
|
||||||
(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.virt_in, "gain", 5.8),
|
(data.virt_in, 'gain', 5.8),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_float_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_float_params(self, index, param, value):
|
||||||
@@ -116,18 +115,20 @@ class TestSetAndGetFloatHigher:
|
|||||||
assert getattr(vban.strip[index], param) == value
|
assert getattr(vban.strip[index], param) == value
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,value",
|
'index,value',
|
||||||
[(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
|
[(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
|
||||||
)
|
)
|
||||||
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
|
def test_it_gets_strip_prefader_levels_and_compares_length_of_array(
|
||||||
|
self, index, value
|
||||||
|
):
|
||||||
assert len(vban.strip[index].levels.prefader) == value
|
assert len(vban.strip[index].levels.prefader) == value
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "potato",
|
data.name != 'potato',
|
||||||
reason="Only test if logged into Potato version",
|
reason='Only test if logged into Potato version',
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, j, value",
|
'index, j, value',
|
||||||
[
|
[
|
||||||
(data.phys_in, 0, -20.7),
|
(data.phys_in, 0, -20.7),
|
||||||
(data.virt_in, 3, -60),
|
(data.virt_in, 3, -60),
|
||||||
@@ -142,14 +143,14 @@ class TestSetAndGetFloatHigher:
|
|||||||
""" strip tests, physical """
|
""" strip tests, physical """
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "potato",
|
data.name != 'potato',
|
||||||
reason="Only test if logged into Potato version",
|
reason='Only test if logged into Potato version',
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param, value",
|
'index, param, value',
|
||||||
[
|
[
|
||||||
(data.phys_in, "gainin", -8.6),
|
(data.phys_in, 'gainin', -8.6),
|
||||||
(data.phys_in, "knee", 0.24),
|
(data.phys_in, 'knee', 0.24),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_strip_comp_params(self, index, param, value):
|
def test_it_sets_strip_comp_params(self, index, param, value):
|
||||||
@@ -158,14 +159,14 @@ class TestSetAndGetFloatHigher:
|
|||||||
# we can set but not get this value. Not in RT Packet.
|
# we can set but not get this value. Not in RT Packet.
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
data.name != "potato",
|
data.name != 'potato',
|
||||||
reason="Only test if logged into Potato version",
|
reason='Only test if logged into Potato version',
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param, value",
|
'index, param, value',
|
||||||
[
|
[
|
||||||
(data.phys_in, "bpsidechain", 120),
|
(data.phys_in, 'bpsidechain', 120),
|
||||||
(data.phys_in, "hold", 3000),
|
(data.phys_in, 'hold', 3000),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
|
||||||
@@ -175,12 +176,13 @@ class TestSetAndGetFloatHigher:
|
|||||||
|
|
||||||
""" strip tests, virtual """
|
""" strip tests, virtual """
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason='Requires RT Packet NBS 1')
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param, value",
|
'index, param, value',
|
||||||
[
|
[
|
||||||
(data.virt_in, "treble", -1.6),
|
(data.virt_in, 'treble', -1.6),
|
||||||
(data.virt_in, "mid", 5.8),
|
(data.virt_in, 'mid', 5.8),
|
||||||
(data.virt_in, "bass", -8.1),
|
(data.virt_in, 'bass', -8.1),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
|
||||||
@@ -190,30 +192,30 @@ class TestSetAndGetFloatHigher:
|
|||||||
""" bus tests, physical and virtual """
|
""" bus tests, physical and virtual """
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param, value",
|
'index, param, value',
|
||||||
[(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)],
|
[(data.phys_out, 'gain', -3.6), (data.virt_out, 'gain', 5.8)],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_bus_float_params(self, index, param, value):
|
def test_it_sets_and_gets_bus_float_params(self, index, param, value):
|
||||||
setattr(vban.bus[index], param, value)
|
setattr(vban.bus[index], param, value)
|
||||||
assert getattr(vban.bus[index], param) == value
|
assert getattr(vban.bus[index], param) == value
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index,value",
|
'index,value',
|
||||||
[(data.phys_out, 8), (data.virt_out, 8)],
|
[(data.phys_out, 8), (data.virt_out, 8)],
|
||||||
)
|
)
|
||||||
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
|
def test_it_gets_bus_levels_and_compares_length_of_array(self, index, value):
|
||||||
assert len(vban.bus[index].levels.all) == value
|
assert len(vban.bus[index].levels.all) == value
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["test0", "test1"])
|
@pytest.mark.parametrize('value', ['test0', 'test1'])
|
||||||
class TestSetAndGetStringHigher:
|
class TestSetAndGetStringHigher:
|
||||||
__test__ = True
|
__test__ = True
|
||||||
|
|
||||||
"""strip tests, physical and virtual"""
|
"""strip tests, physical and virtual"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param",
|
'index, param',
|
||||||
[(data.phys_in, "label"), (data.virt_in, "label")],
|
[(data.phys_in, 'label'), (data.virt_in, 'label')],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_strip_string_params(self, index, param, value):
|
def test_it_sets_and_gets_strip_string_params(self, index, param, value):
|
||||||
setattr(vban.strip[index], param, value)
|
setattr(vban.strip[index], param, value)
|
||||||
@@ -222,8 +224,8 @@ class TestSetAndGetStringHigher:
|
|||||||
""" bus tests, physical and virtual """
|
""" bus tests, physical and virtual """
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"index, param",
|
'index, param',
|
||||||
[(data.phys_out, "label"), (data.virt_out, "label")],
|
[(data.phys_out, 'label'), (data.virt_out, 'label')],
|
||||||
)
|
)
|
||||||
def test_it_sets_and_gets_bus_string_params(self, index, param, value):
|
def test_it_sets_and_gets_bus_string_params(self, index, param, value):
|
||||||
setattr(vban.bus[index], param, value)
|
setattr(vban.bus[index], param, value)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests import data, vban
|
from tests import data, vban
|
||||||
@@ -11,31 +9,26 @@ class TestPublicPacketLower:
|
|||||||
|
|
||||||
"""Tests for a valid rt data packet"""
|
"""Tests for a valid rt data packet"""
|
||||||
|
|
||||||
def test_it_gets_an_rt_data_packet(self):
|
def test_it_gets_an_rt0_data_packet(self):
|
||||||
assert vban.public_packet.voicemeetertype in (
|
assert vban.public_packets[0].voicemeetertype in (
|
||||||
kind.name for kind in kinds.kinds_all
|
kind.name for kind in kinds.all
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.parametrize('value', [0, 1])
|
||||||
"not config.getoption('--run-slow')",
|
|
||||||
reason="Only run when --run-slow is given",
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize("value", [0, 1])
|
|
||||||
class TestSetRT:
|
class TestSetRT:
|
||||||
__test__ = True
|
__test__ = True
|
||||||
|
|
||||||
"""Tests set_rt"""
|
"""Tests set_rt"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"kls,index,param",
|
'kls,index,param',
|
||||||
[
|
[
|
||||||
("strip", data.phys_in, "mute"),
|
('strip', data.phys_in, 'mute'),
|
||||||
("bus", data.virt_out, "mono"),
|
('bus', data.virt_out, 'mono'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_it_sends_a_text_request(self, kls, index, param, value):
|
def test_it_sends_a_text_request(self, kls, index, param, value):
|
||||||
vban._set_rt(f"{kls}[{index}]", param, value)
|
vban._set_rt(f'{kls}[{index}].{param}', value)
|
||||||
time.sleep(0.02)
|
|
||||||
target = getattr(vban, kls)[index]
|
target = getattr(vban, kls)[index]
|
||||||
assert getattr(target, param) == bool(value)
|
assert getattr(target, param) == bool(value)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from .factory import request_vbancmd_obj as api
|
from .factory import request_vbancmd_obj as api
|
||||||
|
|
||||||
__ALL__ = ["api"]
|
__ALL__ = ['api']
|
||||||
|
|||||||
137
vban_cmd/bus.py
137
vban_cmd/bus.py
@@ -1,16 +1,10 @@
|
|||||||
|
import abc
|
||||||
import time
|
import time
|
||||||
from abc import abstractmethod
|
|
||||||
from enum import IntEnum
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
|
from .enums import NBS, BusModes
|
||||||
from .iremote import IRemote
|
from .iremote import IRemote
|
||||||
from .meta import bus_mode_prop, channel_bool_prop, channel_label_prop
|
from .meta import bus_mode_prop, channel_bool_prop, channel_int_prop, channel_label_prop
|
||||||
|
|
||||||
BusModes = IntEnum(
|
|
||||||
"BusModes",
|
|
||||||
"normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly",
|
|
||||||
start=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Bus(IRemote):
|
class Bus(IRemote):
|
||||||
@@ -20,35 +14,32 @@ class Bus(IRemote):
|
|||||||
Defines concrete implementation for bus
|
Defines concrete implementation for bus
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abc.abstractmethod
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Bus[{self.index}]"
|
return f'bus[{self.index}]'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gain(self) -> float:
|
def gain(self) -> float:
|
||||||
def fget():
|
val = self.getter('gain')
|
||||||
val = self.public_packet.busgain[self.index]
|
if val:
|
||||||
if 0 <= val <= 1200:
|
return round(val, 2)
|
||||||
return val * 0.01
|
else:
|
||||||
return (((1 << 16) - 1) - val) * -0.01
|
return self.public_packets[NBS.zero].busgain[self.index]
|
||||||
|
|
||||||
val = self.getter("gain")
|
|
||||||
return round(val if val else fget(), 1)
|
|
||||||
|
|
||||||
@gain.setter
|
@gain.setter
|
||||||
def gain(self, val: float):
|
def gain(self, val: float):
|
||||||
self.setter("gain", val)
|
self.setter('gain', val)
|
||||||
|
|
||||||
def fadeto(self, target: float, time_: int):
|
def fadeto(self, target: float, time_: int):
|
||||||
self.setter("FadeTo", f"({target}, {time_})")
|
self.setter('FadeTo', f'({target}, {time_})')
|
||||||
time.sleep(self._remote.DELAY)
|
time.sleep(self._remote.DELAY)
|
||||||
|
|
||||||
def fadeby(self, change: float, time_: int):
|
def fadeby(self, change: float, time_: int):
|
||||||
self.setter("FadeBy", f"({change}, {time_})")
|
self.setter('FadeBy', f'({change}, {time_})')
|
||||||
time.sleep(self._remote.DELAY)
|
time.sleep(self._remote.DELAY)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,22 +47,22 @@ class BusEQ(IRemote):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def make(cls, remote, index):
|
def make(cls, remote, index):
|
||||||
BUSEQ_cls = type(
|
BUSEQ_cls = type(
|
||||||
f"BusEQ{remote.kind}",
|
f'BusEQ{remote.kind}',
|
||||||
(cls,),
|
(cls,),
|
||||||
{
|
{
|
||||||
**{param: channel_bool_prop(param) for param in ["on", "ab"]},
|
**{param: channel_bool_prop(param) for param in ['on', 'ab']},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return BUSEQ_cls(remote, index)
|
return BUSEQ_cls(remote, index)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Bus[{self.index}].eq"
|
return f'bus[{self.index}].eq'
|
||||||
|
|
||||||
|
|
||||||
class PhysicalBus(Bus):
|
class PhysicalBus(Bus):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{type(self).__name__}{self.index}"
|
return f'{type(self).__name__}{self.index}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device(self) -> str:
|
def device(self) -> str:
|
||||||
@@ -84,7 +75,7 @@ class PhysicalBus(Bus):
|
|||||||
|
|
||||||
class VirtualBus(Bus):
|
class VirtualBus(Bus):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{type(self).__name__}{self.index}"
|
return f'{type(self).__name__}{self.index}'
|
||||||
|
|
||||||
|
|
||||||
class BusLevel(IRemote):
|
class BusLevel(IRemote):
|
||||||
@@ -99,24 +90,13 @@ class BusLevel(IRemote):
|
|||||||
def getter(self):
|
def getter(self):
|
||||||
"""Returns a tuple of level values for the channel."""
|
"""Returns a tuple of level values for the channel."""
|
||||||
|
|
||||||
def fget(i):
|
if not self._remote.stopped() and self._remote.event.ldirty:
|
||||||
return round((((1 << 16) - 1) - i) * -0.01, 1)
|
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]]
|
||||||
if self._remote.running and self._remote.event.ldirty:
|
|
||||||
return tuple(
|
|
||||||
fget(i)
|
|
||||||
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]]
|
|
||||||
)
|
|
||||||
return tuple(
|
|
||||||
fget(i)
|
|
||||||
for i in self._remote._get_levels(self.public_packet)[1][
|
|
||||||
self.range[0] : self.range[-1]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Bus[{self.index}]"
|
return f'bus[{self.index}]'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all(self) -> tuple:
|
def all(self) -> tuple:
|
||||||
@@ -137,37 +117,51 @@ class BusLevel(IRemote):
|
|||||||
def _make_bus_mode_mixin():
|
def _make_bus_mode_mixin():
|
||||||
"""Creates a mixin of Bus Modes."""
|
"""Creates a mixin of Bus Modes."""
|
||||||
|
|
||||||
|
mode_names = [
|
||||||
|
'normal',
|
||||||
|
'amix',
|
||||||
|
'repeat',
|
||||||
|
'bmix',
|
||||||
|
'composite',
|
||||||
|
'tvmix',
|
||||||
|
'upmix21',
|
||||||
|
'upmix41',
|
||||||
|
'upmix61',
|
||||||
|
'centeronly',
|
||||||
|
'lfeonly',
|
||||||
|
'rearonly',
|
||||||
|
]
|
||||||
|
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Bus[{self.index}].mode"
|
return f'bus[{self.index}].mode'
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
time.sleep(0.01)
|
"""Get current bus mode using ChannelState for clean bit extraction."""
|
||||||
for i, val in enumerate(
|
mode_cache_items = [
|
||||||
[
|
(k, v)
|
||||||
self.amix,
|
for k, v in self._remote.cache.items()
|
||||||
self.bmix,
|
if k.startswith(f'{self.identifier}.') and v == 1
|
||||||
self.repeat,
|
]
|
||||||
self.composite,
|
|
||||||
self.tvmix,
|
if mode_cache_items:
|
||||||
self.upmix21,
|
latest_cached = mode_cache_items[-1][0]
|
||||||
self.upmix41,
|
mode_name = latest_cached.split('.')[-1]
|
||||||
self.upmix61,
|
return mode_name
|
||||||
self.centeronly,
|
|
||||||
self.lfeonly,
|
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||||
self.rearonly,
|
|
||||||
]
|
# Extract bus mode from bits 4-7 (mask 0xF0, shift right by 4)
|
||||||
):
|
mode_value = (bus_state._state & 0x000000F0) >> 4
|
||||||
if val:
|
|
||||||
return BusModes(i + 1).name
|
return mode_names[mode_value] if mode_value < len(mode_names) else 'normal'
|
||||||
return "normal"
|
|
||||||
|
|
||||||
return type(
|
return type(
|
||||||
"BusModeMixin",
|
'BusModeMixin',
|
||||||
(IRemote,),
|
(IRemote,),
|
||||||
{
|
{
|
||||||
"identifier": property(identifier),
|
'identifier': property(identifier),
|
||||||
**{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
|
**{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
|
||||||
"get": get,
|
'get': get,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,14 +175,15 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
|
|||||||
BUS_cls = PhysicalBus if phys_bus else VirtualBus
|
BUS_cls = PhysicalBus if phys_bus else VirtualBus
|
||||||
BUSMODEMIXIN_cls = _make_bus_mode_mixin()
|
BUSMODEMIXIN_cls = _make_bus_mode_mixin()
|
||||||
return type(
|
return type(
|
||||||
f"{BUS_cls.__name__}{remote.kind}",
|
f'{BUS_cls.__name__}{remote.kind}',
|
||||||
(BUS_cls,),
|
(BUS_cls,),
|
||||||
{
|
{
|
||||||
"eq": BusEQ.make(remote, i),
|
'eq': BusEQ.make(remote, i),
|
||||||
"levels": BusLevel(remote, i),
|
'levels': BusLevel(remote, i),
|
||||||
"mode": BUSMODEMIXIN_cls(remote, i),
|
'mode': BUSMODEMIXIN_cls(remote, i),
|
||||||
**{param: channel_bool_prop(param) for param in ["mute", "mono"]},
|
**{param: channel_bool_prop(param) for param in ('mute',)},
|
||||||
"label": channel_label_prop(),
|
**{param: channel_int_prop(param) for param in ('mono',)},
|
||||||
|
'label': channel_label_prop(),
|
||||||
},
|
},
|
||||||
)(remote, i)
|
)(remote, i)
|
||||||
|
|
||||||
|
|||||||
@@ -17,30 +17,30 @@ class Command(IRemote):
|
|||||||
Returns a Command class of a kind.
|
Returns a Command class of a kind.
|
||||||
"""
|
"""
|
||||||
CMD_cls = type(
|
CMD_cls = type(
|
||||||
f"Command{remote.kind}",
|
f'Command{remote.kind}',
|
||||||
(cls,),
|
(cls,),
|
||||||
{
|
{
|
||||||
**{
|
**{
|
||||||
param: action_fn(param) for param in ["show", "shutdown", "restart"]
|
param: action_fn(param) for param in ['show', 'shutdown', 'restart']
|
||||||
},
|
},
|
||||||
"hide": action_fn("show", val=0),
|
'hide': action_fn('show', val=0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return CMD_cls(remote)
|
return CMD_cls(remote)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return "Command"
|
return 'command'
|
||||||
|
|
||||||
def set_showvbanchat(self, val: bool):
|
def set_showvbanchat(self, val: bool):
|
||||||
self.setter("DialogShow.VBANCHAT", 1 if val else 0)
|
self.setter('DialogShow.VBANCHAT', 1 if val else 0)
|
||||||
|
|
||||||
showvbanchat = property(fset=set_showvbanchat)
|
showvbanchat = property(fset=set_showvbanchat)
|
||||||
|
|
||||||
def set_lock(self, val: bool):
|
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)
|
lock = property(fset=set_lock)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self._remote.apply_config("reset")
|
self._remote.apply_config('reset')
|
||||||
|
|||||||
@@ -20,73 +20,73 @@ class TOMLStrBuilder:
|
|||||||
def __init__(self, kind):
|
def __init__(self, kind):
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.higher = itertools.chain(
|
self.higher = itertools.chain(
|
||||||
[f"strip-{i}" for i in range(kind.num_strip)],
|
[f'strip-{i}' for i in range(kind.num_strip)],
|
||||||
[f"bus-{i}" for i in range(kind.num_bus)],
|
[f'bus-{i}' for i in range(kind.num_bus)],
|
||||||
)
|
)
|
||||||
|
|
||||||
def init_config(self, profile=None):
|
def init_config(self, profile=None):
|
||||||
self.virt_strip_params = (
|
self.virt_strip_params = (
|
||||||
[
|
[
|
||||||
"mute = false",
|
'mute = false',
|
||||||
"mono = false",
|
'mono = false',
|
||||||
"solo = false",
|
'solo = false',
|
||||||
"gain = 0.0",
|
'gain = 0.0',
|
||||||
]
|
]
|
||||||
+ [f"A{i} = false" for i in range(1, self.kind.phys_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)]
|
+ [f'B{i} = false' for i in range(1, self.kind.virt_out + 1)]
|
||||||
)
|
)
|
||||||
self.phys_strip_params = self.virt_strip_params + [
|
self.phys_strip_params = self.virt_strip_params + [
|
||||||
"comp.knob = 0.0",
|
'comp.knob = 0.0',
|
||||||
"gate.knob = 0.0",
|
'gate.knob = 0.0',
|
||||||
"denoiser.knob = 0.0",
|
'denoiser.knob = 0.0',
|
||||||
"eq.on = false",
|
'eq.on = false',
|
||||||
]
|
]
|
||||||
self.bus_float = ["gain = 0.0"]
|
self.bus_float = ['gain = 0.0']
|
||||||
self.bus_params = [
|
self.bus_params = [
|
||||||
"mono = false",
|
'mono = false',
|
||||||
"eq.on = false",
|
'eq.on = false',
|
||||||
"mute = false",
|
'mute = false',
|
||||||
"gain = 0.0",
|
'gain = 0.0',
|
||||||
]
|
]
|
||||||
|
|
||||||
if profile == "reset":
|
if profile == 'reset':
|
||||||
self.reset_config()
|
self.reset_config()
|
||||||
|
|
||||||
def reset_config(self):
|
def reset_config(self):
|
||||||
self.phys_strip_params = list(
|
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(
|
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)
|
self.init_config(profile)
|
||||||
toml_str = str()
|
toml_str = str()
|
||||||
for eachclass in self.higher:
|
for eachclass in self.higher:
|
||||||
toml_str += f"[{eachclass}]\n"
|
toml_str += f'[{eachclass}]\n'
|
||||||
toml_str = self.join(eachclass, toml_str)
|
toml_str = self.join(eachclass, toml_str)
|
||||||
return toml_str
|
return toml_str
|
||||||
|
|
||||||
def join(self, eachclass, toml_str):
|
def join(self, eachclass, toml_str):
|
||||||
kls, index = eachclass.split("-")
|
kls, index = eachclass.split('-')
|
||||||
match kls:
|
match kls:
|
||||||
case "strip":
|
case 'strip':
|
||||||
toml_str += ("\n").join(
|
toml_str += ('\n').join(
|
||||||
self.phys_strip_params
|
self.phys_strip_params
|
||||||
if int(index) < self.kind.phys_in
|
if int(index) < self.kind.phys_in
|
||||||
else self.virt_strip_params
|
else self.virt_strip_params
|
||||||
)
|
)
|
||||||
case "bus":
|
case 'bus':
|
||||||
toml_str += ("\n").join(self.bus_params)
|
toml_str += ('\n').join(self.bus_params)
|
||||||
case _:
|
case _:
|
||||||
pass
|
pass
|
||||||
return toml_str + "\n"
|
return toml_str + '\n'
|
||||||
|
|
||||||
|
|
||||||
class TOMLDataExtractor:
|
class TOMLDataExtractor:
|
||||||
def __init__(self, file):
|
def __init__(self, file):
|
||||||
with open(file, "rb") as f:
|
with open(file, 'rb') as f:
|
||||||
self._data = tomllib.load(f)
|
self._data = tomllib.load(f)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -104,10 +104,10 @@ def dataextraction_factory(file):
|
|||||||
|
|
||||||
this opens the possibility for other parsers to be added
|
this opens the possibility for other parsers to be added
|
||||||
"""
|
"""
|
||||||
if file.suffix == ".toml":
|
if file.suffix == '.toml':
|
||||||
extractor = TOMLDataExtractor
|
extractor = TOMLDataExtractor
|
||||||
else:
|
else:
|
||||||
raise ValueError("Cannot extract data from {}".format(file))
|
raise ValueError('Cannot extract data from {}'.format(file))
|
||||||
return extractor(file)
|
return extractor(file)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,20 +141,25 @@ class Loader(metaclass=SingletonType):
|
|||||||
def defaults(self, kind):
|
def defaults(self, kind):
|
||||||
self.builder = TOMLStrBuilder(kind)
|
self.builder = TOMLStrBuilder(kind)
|
||||||
toml_str = self.builder.build()
|
toml_str = self.builder.build()
|
||||||
self.register("reset", tomllib.loads(toml_str))
|
self.register('reset', tomllib.loads(toml_str))
|
||||||
|
|
||||||
def parse(self, identifier, data):
|
def parse(self, identifier, data):
|
||||||
if identifier in self._configs:
|
if identifier in self._configs:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"config file with name {identifier} already in memory, skipping.."
|
f'config file with name {identifier} already in memory, skipping..'
|
||||||
)
|
)
|
||||||
return False
|
return
|
||||||
self.parser = dataextraction_factory(data)
|
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
|
return True
|
||||||
|
|
||||||
def register(self, identifier, data=None):
|
def register(self, identifier, data=None):
|
||||||
self._configs[identifier] = data if data else self.parser.data
|
self._configs[identifier] = data if data else self.parser.data
|
||||||
self.logger.info(f"config {self.name}/{identifier} loaded into memory")
|
self.logger.info(f'config {self.name}/{identifier} loaded into memory')
|
||||||
|
|
||||||
def deregister(self):
|
def deregister(self):
|
||||||
self._configs.clear()
|
self._configs.clear()
|
||||||
@@ -177,18 +182,18 @@ def loader(kind):
|
|||||||
|
|
||||||
returns configs loaded into memory
|
returns configs loaded into memory
|
||||||
"""
|
"""
|
||||||
logger_loader = logger.getChild("loader")
|
logger_loader = logger.getChild('loader')
|
||||||
loader = Loader(kind)
|
loader = Loader(kind)
|
||||||
|
|
||||||
for path in (
|
for path in (
|
||||||
Path.cwd() / "configs" / kind.name,
|
Path.cwd() / 'configs' / kind.name,
|
||||||
Path.home() / ".config" / "vban-cmd" / kind.name,
|
Path.home() / '.config' / 'vban-cmd' / kind.name,
|
||||||
Path.home() / "Documents" / "Voicemeeter" / "configs" / kind.name,
|
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / kind.name,
|
||||||
):
|
):
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
logger_loader.info(f"Checking [{path}] for TOML config files:")
|
logger_loader.info(f'Checking [{path}] for TOML config files:')
|
||||||
for file in path.glob("*.toml"):
|
for file in path.glob('*.toml'):
|
||||||
identifier = file.with_suffix("").stem
|
identifier = file.with_suffix('').stem
|
||||||
if loader.parse(identifier, file):
|
if loader.parse(identifier, file):
|
||||||
loader.register(identifier)
|
loader.register(identifier)
|
||||||
return loader.configs
|
return loader.configs
|
||||||
@@ -203,5 +208,5 @@ def request_config(kind_id: str):
|
|||||||
try:
|
try:
|
||||||
configs = loader(kindmap(kind_id))
|
configs = loader(kindmap(kind_id))
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise VBANCMDError(f"Unknown Voicemeeter kind {kind_id}")
|
raise VBANCMDError(f'Unknown Voicemeeter kind {kind_id}')
|
||||||
return configs
|
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,6 +1,6 @@
|
|||||||
class VBANCMDError(Exception):
|
class VBANCMDError(Exception):
|
||||||
"""Exception raised when general errors occur"""
|
"""Base VBANCMD Exception class."""
|
||||||
|
|
||||||
|
|
||||||
class VBANCMDConnectionError(Exception):
|
class VBANCMDConnectionError(VBANCMDError):
|
||||||
"""Exception raised when connection/timeout errors occur"""
|
"""Exception raised when connection/timeout errors occur"""
|
||||||
|
|||||||
@@ -12,30 +12,30 @@ class Event:
|
|||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
|
|
||||||
def info(self, msg=None):
|
def info(self, msg=None):
|
||||||
info = (f"{msg} events",) if msg else ()
|
info = (f'{msg} events',) if msg else ()
|
||||||
if self.any():
|
if self.any():
|
||||||
info += (f"now listening for {', '.join(self.get())} events",)
|
info += (f'now listening for {", ".join(self.get())} events',)
|
||||||
else:
|
else:
|
||||||
info += (f"not listening for any events",)
|
info += ('not listening for any events',)
|
||||||
self.logger.info(", ".join(info))
|
self.logger.info(', '.join(info))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pdirty(self) -> bool:
|
def pdirty(self) -> bool:
|
||||||
return self.subs["pdirty"]
|
return self.subs['pdirty']
|
||||||
|
|
||||||
@pdirty.setter
|
@pdirty.setter
|
||||||
def pdirty(self, val: bool):
|
def pdirty(self, val: bool):
|
||||||
self.subs["pdirty"] = val
|
self.subs['pdirty'] = val
|
||||||
self.info(f"pdirty {'added to' if val else 'removed from'}")
|
self.info(f'pdirty {"added to" if val else "removed from"}')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ldirty(self) -> bool:
|
def ldirty(self) -> bool:
|
||||||
return self.subs["ldirty"]
|
return self.subs['ldirty']
|
||||||
|
|
||||||
@ldirty.setter
|
@ldirty.setter
|
||||||
def ldirty(self, val: bool):
|
def ldirty(self, val: bool):
|
||||||
self.subs["ldirty"] = val
|
self.subs['ldirty'] = val
|
||||||
self.info(f"ldirty {'added to' if val else 'removed from'}")
|
self.info(f'ldirty {"added to" if val else "removed from"}')
|
||||||
|
|
||||||
def get(self) -> list:
|
def get(self) -> list:
|
||||||
return [k for k, v in self.subs.items() if v]
|
return [k for k, v in self.subs.items() if v]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import abc
|
||||||
import logging
|
import logging
|
||||||
from abc import abstractmethod
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from typing import Iterable, NoReturn
|
from typing import Iterable
|
||||||
|
|
||||||
from .bus import request_bus_obj as bus
|
from .bus import request_bus_obj as bus
|
||||||
from .command import Command
|
from .command import Command
|
||||||
@@ -10,7 +10,10 @@ from .config import request_config as configs
|
|||||||
from .error import VBANCMDError
|
from .error import VBANCMDError
|
||||||
from .kinds import KindMapClass
|
from .kinds import KindMapClass
|
||||||
from .kinds import request_kind_map as kindmap
|
from .kinds import request_kind_map as kindmap
|
||||||
|
from .macrobutton import MacroButton
|
||||||
|
from .recorder import Recorder
|
||||||
from .strip import request_strip_obj as strip
|
from .strip import request_strip_obj as strip
|
||||||
|
from .vban import request_vban_obj as vban
|
||||||
from .vbancmd import VbanCmd
|
from .vbancmd import VbanCmd
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,22 +26,27 @@ class FactoryBuilder:
|
|||||||
Separates construction from representation.
|
Separates construction from representation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BuilderProgress = IntEnum("BuilderProgress", "strip bus command", start=0)
|
BuilderProgress = IntEnum(
|
||||||
|
'BuilderProgress', 'strip bus command macrobutton vban recorder', start=0
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, factory, kind: KindMapClass):
|
def __init__(self, factory, kind: KindMapClass):
|
||||||
self._factory = factory
|
self._factory = factory
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self._info = (
|
self._info = (
|
||||||
f"Finished building strips for {self._factory}",
|
f'Finished building strips for {self._factory}',
|
||||||
f"Finished building buses for {self._factory}",
|
f'Finished building buses for {self._factory}',
|
||||||
f"Finished building commands for {self._factory}",
|
f'Finished building commands for {self._factory}',
|
||||||
|
f'Finished building macrobuttons for {self._factory}',
|
||||||
|
f'Finished building vban in/out streams for {self._factory}',
|
||||||
|
f'Finished building recorder for {self._factory}',
|
||||||
)
|
)
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
|
|
||||||
def _pinfo(self, name: str) -> NoReturn:
|
def _pinfo(self, name: str) -> None:
|
||||||
"""prints progress status for each step"""
|
"""prints progress status for each step"""
|
||||||
name = name.split("_")[1]
|
name = name.split('_')[1]
|
||||||
self.logger.info(self._info[int(getattr(self.BuilderProgress, name))])
|
self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
|
||||||
|
|
||||||
def make_strip(self):
|
def make_strip(self):
|
||||||
self._factory.strip = tuple(
|
self._factory.strip = tuple(
|
||||||
@@ -58,25 +66,40 @@ class FactoryBuilder:
|
|||||||
self._factory.command = Command.make(self._factory)
|
self._factory.command = Command.make(self._factory)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def make_macrobutton(self):
|
||||||
|
self._factory.button = tuple(MacroButton(self._factory, i) for i in range(80))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def make_vban(self):
|
||||||
|
self._factory.vban = vban(self._factory)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def make_recorder(self):
|
||||||
|
self._factory.recorder = Recorder.make(self._factory)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class FactoryBase(VbanCmd):
|
class FactoryBase(VbanCmd):
|
||||||
"""Base class for factories, subclasses VbanCmd."""
|
"""Base class for factories, subclasses VbanCmd."""
|
||||||
|
|
||||||
def __init__(self, kind_id: str, **kwargs):
|
def __init__(self, kind_id: str, **kwargs):
|
||||||
defaultkwargs = {
|
defaultkwargs = {
|
||||||
"ip": None,
|
'host': 'localhost',
|
||||||
"port": 6980,
|
'port': 6980,
|
||||||
"streamname": "Command1",
|
'streamname': 'Command1',
|
||||||
"bps": 0,
|
'bps': 256000,
|
||||||
"channel": 0,
|
'channel': 0,
|
||||||
"ratelimit": 0.01,
|
'script_ratelimit': 0.05, # 20 commands per second, to avoid overloading Voicemeeter
|
||||||
"timeout": 5,
|
'timeout': 5, # timeout on socket operations, in seconds
|
||||||
"sync": False,
|
'disable_rt_listeners': False,
|
||||||
"pdirty": False,
|
'sync': False,
|
||||||
"ldirty": False,
|
'pdirty': False,
|
||||||
|
'ldirty': False,
|
||||||
}
|
}
|
||||||
if "subs" in kwargs:
|
if 'ip' in kwargs:
|
||||||
defaultkwargs |= kwargs.pop("subs") # for backwards compatibility
|
defaultkwargs['host'] = kwargs.pop('ip') # for backwards compatibility
|
||||||
|
if 'subs' in kwargs:
|
||||||
|
defaultkwargs |= kwargs.pop('subs') # for backwards compatibility
|
||||||
kwargs = defaultkwargs | kwargs
|
kwargs = defaultkwargs | kwargs
|
||||||
self.kind = kindmap(kind_id)
|
self.kind = kindmap(kind_id)
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -85,14 +108,22 @@ class FactoryBase(VbanCmd):
|
|||||||
self.builder.make_strip,
|
self.builder.make_strip,
|
||||||
self.builder.make_bus,
|
self.builder.make_bus,
|
||||||
self.builder.make_command,
|
self.builder.make_command,
|
||||||
|
self.builder.make_macrobutton,
|
||||||
|
self.builder.make_vban,
|
||||||
)
|
)
|
||||||
self._configs = None
|
self._configs = None
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Voicemeeter {self.kind}"
|
return f'Voicemeeter {self.kind}'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
type(self).__name__
|
||||||
|
+ f"({self.kind}, ip='{self.ip}', port={self.port}, streamname='{self.streamname}')"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abc.abstractmethod
|
||||||
def steps(self):
|
def steps(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -143,7 +174,7 @@ class BananaFactory(FactoryBase):
|
|||||||
@property
|
@property
|
||||||
def steps(self) -> Iterable:
|
def steps(self) -> Iterable:
|
||||||
"""steps required to build the interface for a kind"""
|
"""steps required to build the interface for a kind"""
|
||||||
return self._steps
|
return self._steps + (self.builder.make_recorder,)
|
||||||
|
|
||||||
|
|
||||||
class PotatoFactory(FactoryBase):
|
class PotatoFactory(FactoryBase):
|
||||||
@@ -165,7 +196,7 @@ class PotatoFactory(FactoryBase):
|
|||||||
@property
|
@property
|
||||||
def steps(self) -> Iterable:
|
def steps(self) -> Iterable:
|
||||||
"""steps required to build the interface for a kind"""
|
"""steps required to build the interface for a kind"""
|
||||||
return self._steps
|
return self._steps + (self.builder.make_recorder,)
|
||||||
|
|
||||||
|
|
||||||
def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
||||||
@@ -175,15 +206,21 @@ def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
|||||||
Returns a VbanCmd class of a kind
|
Returns a VbanCmd class of a kind
|
||||||
"""
|
"""
|
||||||
match kind_id:
|
match kind_id:
|
||||||
case "basic":
|
case 'basic':
|
||||||
_factory = BasicFactory
|
_factory = BasicFactory
|
||||||
case "banana":
|
case 'banana':
|
||||||
_factory = BananaFactory
|
_factory = BananaFactory
|
||||||
case "potato":
|
case 'potato' | 'matrix':
|
||||||
|
# matrix is a special kind where:
|
||||||
|
# - we don't need to scale the interface with the builder (in other words kind is arbitrary).
|
||||||
|
# - we don't ever need to use real-time listeners, so we disable them to avoid confusion
|
||||||
|
if kind_id == 'matrix':
|
||||||
|
kwargs['disable_rt_listeners'] = True
|
||||||
|
kind_id = 'potato'
|
||||||
_factory = PotatoFactory
|
_factory = PotatoFactory
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
|
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
|
||||||
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:
|
def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
||||||
@@ -192,12 +229,12 @@ def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
|||||||
|
|
||||||
Returns a reference to a VbanCmd class of a kind
|
Returns a reference to a VbanCmd class of a kind
|
||||||
"""
|
"""
|
||||||
logger_entry = logger.getChild("factory.request_vbancmd_obj")
|
logger_entry = logger.getChild('factory.request_vbancmd_obj')
|
||||||
|
|
||||||
VBANCMD_obj = None
|
VBANCMD_obj = None
|
||||||
try:
|
try:
|
||||||
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
|
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
logger_entry.exception(f"{type(e).__name__}: {e}")
|
logger_entry.exception(f'{type(e).__name__}: {e}')
|
||||||
raise VBANCMDError(str(e)) from e
|
raise VBANCMDError(str(e)) from e
|
||||||
return VBANCMD_obj
|
return VBANCMD_obj
|
||||||
|
|||||||
@@ -1,84 +1,10 @@
|
|||||||
|
import abc
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from abc import ABCMeta, abstractmethod
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class IRemote(abc.ABC):
|
||||||
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
|
|
||||||
|
|
||||||
_on: hex = 0x00000100 # eq.on
|
|
||||||
_cross: hex = 0x00000200
|
|
||||||
_ab: hex = 0x00000800 # eq.ab
|
|
||||||
|
|
||||||
_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):
|
|
||||||
"""
|
"""
|
||||||
Common interface between base class and extended (higher) classes
|
Common interface between base class and extended (higher) classes
|
||||||
|
|
||||||
@@ -89,11 +15,10 @@ class IRemote(metaclass=ABCMeta):
|
|||||||
self._remote = remote
|
self._remote = remote
|
||||||
self.index = index
|
self.index = index
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
self._modes = Modes()
|
|
||||||
|
|
||||||
def getter(self, param):
|
def getter(self, param):
|
||||||
cmd = self._cmd(param)
|
cmd = self._cmd(param)
|
||||||
self.logger.debug(f"getter: {cmd}")
|
self.logger.debug(f'getter: {cmd}')
|
||||||
if cmd in self._remote.cache:
|
if cmd in self._remote.cache:
|
||||||
return self._remote.cache.pop(cmd)
|
return self._remote.cache.pop(cmd)
|
||||||
if self._remote.sync:
|
if self._remote.sync:
|
||||||
@@ -101,32 +26,35 @@ class IRemote(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def setter(self, param, val):
|
def setter(self, param, val):
|
||||||
"""Sends a string request RT packet."""
|
"""Sends a string request RT packet."""
|
||||||
self.logger.debug(f"setter: {self._cmd(param)}={val}")
|
self.logger.debug(f'setter: {self._cmd(param)}={val}')
|
||||||
self._remote._set_rt(self.identifier, param, val)
|
self._remote._set_rt(self._cmd(param), val)
|
||||||
|
|
||||||
def _cmd(self, param):
|
def _cmd(self, param):
|
||||||
cmd = (self.identifier,)
|
cmd = (self.identifier,)
|
||||||
if param:
|
if param:
|
||||||
cmd += (f".{param}",)
|
cmd += (f'.{param}',)
|
||||||
return "".join(cmd)
|
return ''.join(cmd)
|
||||||
|
|
||||||
@abstractmethod
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
def identifier(self):
|
def identifier(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_packet(self):
|
def public_packets(self):
|
||||||
"""Returns an RT data packet."""
|
"""Returns an RT data packet."""
|
||||||
return self._remote.public_packet
|
return self._remote.public_packets
|
||||||
|
|
||||||
def apply(self, data):
|
def apply(self, data):
|
||||||
"""Sets all parameters of a dict for the channel."""
|
"""Sets all parameters of a dict for the channel."""
|
||||||
|
|
||||||
|
script = ''
|
||||||
|
|
||||||
def fget(attr, val):
|
def fget(attr, val):
|
||||||
if attr == "mode":
|
if attr == 'mode':
|
||||||
return (f"mode.{val}", 1)
|
return (f'mode.{val}', 1)
|
||||||
elif attr == "knob":
|
elif attr == 'knob':
|
||||||
return ("", val)
|
return ('', val)
|
||||||
return (attr, val)
|
return (attr, val)
|
||||||
|
|
||||||
for attr, val in data.items():
|
for attr, val in data.items():
|
||||||
@@ -137,14 +65,9 @@ class IRemote(metaclass=ABCMeta):
|
|||||||
val = 1 if val else 0
|
val = 1 if val else 0
|
||||||
|
|
||||||
self._remote.cache[self._cmd(attr)] = val
|
self._remote.cache[self._cmd(attr)] = val
|
||||||
self._remote._script += f"{self._cmd(attr)}={val};"
|
script += f'{self._cmd(attr)}={val};'
|
||||||
else:
|
else:
|
||||||
target = getattr(self, attr)
|
target = getattr(self, attr)
|
||||||
target.apply(val)
|
target.apply(val)
|
||||||
return self
|
|
||||||
|
|
||||||
def then_wait(self):
|
self._remote.sendtext(script)
|
||||||
self.logger.debug(self._remote._script)
|
|
||||||
self._remote.sendtext(self._remote._script)
|
|
||||||
self._remote._script = str()
|
|
||||||
time.sleep(self._remote.DELAY)
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, unique
|
|
||||||
|
|
||||||
|
from .enums import KindId
|
||||||
from .error import VBANCMDError
|
from .error import VBANCMDError
|
||||||
|
|
||||||
|
|
||||||
@unique
|
|
||||||
class KindId(Enum):
|
|
||||||
BASIC = 1
|
|
||||||
BANANA = 2
|
|
||||||
POTATO = 3
|
|
||||||
|
|
||||||
|
|
||||||
class SingletonType(type):
|
class SingletonType(type):
|
||||||
"""ensure only a single instance of a kind map object"""
|
"""ensure only a single instance of a kind map object"""
|
||||||
|
|
||||||
@@ -22,12 +15,15 @@ class SingletonType(type):
|
|||||||
return cls._instances[cls]
|
return cls._instances[cls]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class KindMapClass(metaclass=SingletonType):
|
class KindMapClass(metaclass=SingletonType):
|
||||||
name: str
|
name: str
|
||||||
ins: tuple
|
ins: tuple
|
||||||
outs: tuple
|
outs: tuple
|
||||||
vban: tuple
|
vban: tuple
|
||||||
|
strip_channels: int
|
||||||
|
bus_channels: int
|
||||||
|
cells: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def phys_in(self):
|
def phys_in(self):
|
||||||
@@ -53,44 +49,61 @@ class KindMapClass(metaclass=SingletonType):
|
|||||||
def num_bus(self):
|
def num_bus(self):
|
||||||
return sum(self.outs)
|
return sum(self.outs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_strip_levels(self) -> int:
|
||||||
|
return 2 * self.phys_in + 8 * self.virt_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_bus_levels(self) -> int:
|
||||||
|
return 8 * (self.phys_out + self.virt_out)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name.capitalize()
|
return self.name.capitalize()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class BasicMap(KindMapClass):
|
class BasicMap(KindMapClass):
|
||||||
name: str
|
name: str
|
||||||
ins: tuple = (2, 1)
|
ins: tuple = (2, 1)
|
||||||
outs: tuple = (1, 1)
|
outs: tuple = (1, 1)
|
||||||
vban: tuple = (4, 4)
|
vban: tuple = (4, 4, 1, 1)
|
||||||
|
strip_channels: int = 0
|
||||||
|
bus_channels: int = 0
|
||||||
|
cells: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class BananaMap(KindMapClass):
|
class BananaMap(KindMapClass):
|
||||||
name: str
|
name: str
|
||||||
ins: tuple = (3, 2)
|
ins: tuple = (3, 2)
|
||||||
outs: tuple = (3, 2)
|
outs: tuple = (3, 2)
|
||||||
vban: tuple = (8, 8)
|
vban: tuple = (8, 8, 1, 1)
|
||||||
|
strip_channels: int = 0
|
||||||
|
bus_channels: int = 8
|
||||||
|
cells: int = 6
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True)
|
||||||
class PotatoMap(KindMapClass):
|
class PotatoMap(KindMapClass):
|
||||||
name: str
|
name: str
|
||||||
ins: tuple = (5, 3)
|
ins: tuple = (5, 3)
|
||||||
outs: tuple = (5, 3)
|
outs: tuple = (5, 3)
|
||||||
vban: tuple = (8, 8)
|
vban: tuple = (8, 8, 1, 1)
|
||||||
|
strip_channels: int = 2
|
||||||
|
bus_channels: int = 8
|
||||||
|
cells: int = 6
|
||||||
|
|
||||||
|
|
||||||
def kind_factory(kind_id):
|
def kind_factory(kind_id):
|
||||||
match kind_id:
|
match kind_id:
|
||||||
case "basic":
|
case 'basic':
|
||||||
_kind_map = BasicMap
|
_kind_map = BasicMap
|
||||||
case "banana":
|
case 'banana':
|
||||||
_kind_map = BananaMap
|
_kind_map = BananaMap
|
||||||
case "potato":
|
case 'potato':
|
||||||
_kind_map = PotatoMap
|
_kind_map = PotatoMap
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"Unknown Voicemeeter kind {kind_id}")
|
raise ValueError(f'Unknown Voicemeeter kind {kind_id}')
|
||||||
return _kind_map(name=kind_id)
|
return _kind_map(name=kind_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,4 +116,4 @@ def request_kind_map(kind_id):
|
|||||||
return KIND_obj
|
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')
|
||||||
174
vban_cmd/meta.py
174
vban_cmd/meta.py
@@ -1,6 +1,8 @@
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
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):
|
def channel_bool_prop(param):
|
||||||
@@ -8,17 +10,25 @@ def channel_bool_prop(param):
|
|||||||
|
|
||||||
@partial(cache_bool, param=param)
|
@partial(cache_bool, param=param)
|
||||||
def fget(self):
|
def fget(self):
|
||||||
return (
|
cmd = self._cmd(param)
|
||||||
not int.from_bytes(
|
self.logger.debug(f'getter: {cmd}')
|
||||||
getattr(
|
|
||||||
self.public_packet,
|
states = self.public_packets[NBS.zero].states
|
||||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}state",
|
channel_states = (
|
||||||
)[self.index],
|
states.strip if 'strip' in type(self).__name__.lower() else states.bus
|
||||||
"little",
|
|
||||||
)
|
|
||||||
& getattr(self._modes, f"_{param.lower()}")
|
|
||||||
== 0
|
|
||||||
)
|
)
|
||||||
|
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):
|
def fset(self, val):
|
||||||
self.setter(param, 1 if val else 0)
|
self.setter(param, 1 if val else 0)
|
||||||
@@ -26,18 +36,48 @@ def channel_bool_prop(param):
|
|||||||
return property(fget, fset)
|
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():
|
def channel_label_prop():
|
||||||
"""meta function for channel label parameters"""
|
"""meta function for channel label parameters"""
|
||||||
|
|
||||||
@partial(cache_string, param="label")
|
@partial(cache_string, param='label')
|
||||||
def fget(self) -> str:
|
def fget(self) -> str:
|
||||||
return getattr(
|
if 'strip' in type(self).__name__.lower():
|
||||||
self.public_packet,
|
return self.public_packets[NBS.zero].labels.strip[self.index]
|
||||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}labels",
|
else:
|
||||||
)[self.index]
|
return self.public_packets[NBS.zero].labels.bus[self.index]
|
||||||
|
|
||||||
def fset(self, val: str):
|
def fset(self, val: str):
|
||||||
self.setter("label", str(val))
|
self.setter('label', f'"{val}"')
|
||||||
|
|
||||||
return property(fget, fset)
|
return property(fget, fset)
|
||||||
|
|
||||||
@@ -47,11 +87,12 @@ def strip_output_prop(param):
|
|||||||
|
|
||||||
@partial(cache_bool, param=param)
|
@partial(cache_bool, param=param)
|
||||||
def fget(self):
|
def fget(self):
|
||||||
return (
|
cmd = self._cmd(param)
|
||||||
not int.from_bytes(self.public_packet.stripstate[self.index], "little")
|
self.logger.debug(f'getter: {cmd}')
|
||||||
& getattr(self._modes, f"_bus{param.lower()}")
|
|
||||||
== 0
|
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):
|
def fset(self, val):
|
||||||
self.setter(param, 1 if val else 0)
|
self.setter(param, 1 if val else 0)
|
||||||
@@ -64,26 +105,17 @@ def bus_mode_prop(param):
|
|||||||
|
|
||||||
@partial(cache_bool, param=param)
|
@partial(cache_bool, param=param)
|
||||||
def fget(self):
|
def fget(self):
|
||||||
modelist = {
|
cmd = self._cmd(param)
|
||||||
"amix": (1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1),
|
self.logger.debug(f'getter: {cmd}')
|
||||||
"repeat": (0, 2, 2, 0, 0, 2, 2, 0, 0, 2, 2),
|
|
||||||
"bmix": (1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3),
|
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||||
"composite": (0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0),
|
|
||||||
"tvmix": (1, 0, 1, 4, 5, 4, 5, 0, 1, 0, 1),
|
# Extract current bus mode from bits 4-7
|
||||||
"upmix21": (0, 2, 2, 4, 4, 6, 6, 0, 0, 2, 2),
|
current_mode = (bus_state._state & 0x000000F0) >> 4
|
||||||
"upmix41": (1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3),
|
|
||||||
"upmix61": (0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8),
|
expected_mode = getattr(BusModes, param.lower())
|
||||||
"centeronly": (1, 0, 1, 0, 1, 0, 1, 8, 9, 8, 9),
|
|
||||||
"lfeonly": (0, 2, 2, 0, 0, 2, 2, 8, 8, 10, 10),
|
return current_mode == expected_mode
|
||||||
"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]
|
|
||||||
|
|
||||||
def fset(self, val):
|
def fset(self, val):
|
||||||
self.setter(param, 1 if val else 0)
|
self.setter(param, 1 if val else 0)
|
||||||
@@ -98,3 +130,61 @@ def action_fn(param, val=1):
|
|||||||
self.setter(param, val)
|
self.setter(param, val)
|
||||||
|
|
||||||
return fdo
|
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,294 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from .kinds import KindMapClass
|
|
||||||
from .util import comp
|
|
||||||
|
|
||||||
VBAN_SERVICE_RTPACKETREGISTER = 32
|
|
||||||
VBAN_SERVICE_RTPACKET = 33
|
|
||||||
MAX_PACKET_SIZE = 1436
|
|
||||||
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 + 4
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class VbanRtPacket:
|
|
||||||
"""Represents the body of a VBAN RT data packet"""
|
|
||||||
|
|
||||||
_kind: KindMapClass
|
|
||||||
_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 _generate_levels(self, levelarray) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(levelarray[i : i + 2], "little")
|
|
||||||
for i in range(0, len(levelarray), 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def strip_levels(self):
|
|
||||||
return self._generate_levels(self._inputLeveldB100)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bus_levels(self):
|
|
||||||
return self._generate_levels(self._outputLeveldB100)
|
|
||||||
|
|
||||||
def pdirty(self, other) -> bool:
|
|
||||||
"""True iff any defined parameter has changed"""
|
|
||||||
|
|
||||||
return not (
|
|
||||||
self._stripState == other._stripState
|
|
||||||
and self._busState == other._busState
|
|
||||||
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
|
|
||||||
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
|
|
||||||
and self._busLabelUTF8c60 == other._busLabelUTF8c60
|
|
||||||
)
|
|
||||||
|
|
||||||
def ldirty(self, strip_cache, bus_cache) -> bool:
|
|
||||||
self._strip_comp, self._bus_comp = (
|
|
||||||
tuple(not val for val in comp(strip_cache, self.strip_levels)),
|
|
||||||
tuple(not val for val in comp(bus_cache, self.bus_levels)),
|
|
||||||
)
|
|
||||||
return any(any(l) for l in (self._strip_comp, self._bus_comp))
|
|
||||||
|
|
||||||
@property
|
|
||||||
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) -> tuple:
|
|
||||||
"""returns the entire level array across all inputs for a kind"""
|
|
||||||
return self.strip_levels[0 : (2 * self._kind.phys_in + 8 * self._kind.virt_in)]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outputlevels(self) -> tuple:
|
|
||||||
"""returns the entire level array across all outputs for a kind"""
|
|
||||||
return self.bus_levels[0 : 8 * self._kind.num_bus]
|
|
||||||
|
|
||||||
@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(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer1[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer2(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer2[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer3(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer3[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer4(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer4[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer5(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer5[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer6(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer6[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer7(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
int.from_bytes(self._stripGaindB100Layer7[i : i + 2], "little")
|
|
||||||
for i in range(0, 16, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stripgainlayer8(self) -> tuple:
|
|
||||||
return tuple(
|
|
||||||
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(
|
|
||||||
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 VbanRtPacketHeader:
|
|
||||||
"""Represents the header of VBAN RT data packet"""
|
|
||||||
|
|
||||||
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 RequestHeader:
|
|
||||||
"""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 SubscribeHeader:
|
|
||||||
"""Represents a packet used to subscribe to the RT Packet Service"""
|
|
||||||
|
|
||||||
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
|
||||||
345
vban_cmd/packet/headers.py
Normal file
345
vban_cmd/packet/headers.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
data.extend(header.vban)
|
||||||
|
data.extend(header.format_sr.to_bytes(1, 'little'))
|
||||||
|
data.extend(header.format_nbs.to_bytes(1, 'little'))
|
||||||
|
data.extend(header.format_nbc.to_bytes(1, 'little'))
|
||||||
|
data.extend(header.format_bit.to_bytes(1, 'little'))
|
||||||
|
data.extend(header.streamname)
|
||||||
|
data.extend(header.framecounter.to_bytes(4, 'little'))
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
data.extend(header.vban)
|
||||||
|
data.extend(header.format_sr)
|
||||||
|
data.extend(header.format_nbs)
|
||||||
|
data.extend(header.format_nbc)
|
||||||
|
data.extend(header.format_bit)
|
||||||
|
data.extend(header.streamname)
|
||||||
|
data.extend(framecounter.to_bytes(4, 'little'))
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
data.extend(header.vban)
|
||||||
|
data.extend(header.sr)
|
||||||
|
data.extend(header.nbs)
|
||||||
|
data.extend(header.nbc)
|
||||||
|
data.extend(header.bit)
|
||||||
|
data.extend(header.streamname)
|
||||||
|
data.extend(header.framecounter.to_bytes(4, 'little'))
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
@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
|
||||||
289
vban_cmd/packet/nbs0.py
Normal file
289
vban_cmd/packet/nbs0.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
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')
|
||||||
|
|
||||||
|
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"""
|
||||||
|
|
||||||
|
self_gains = (
|
||||||
|
self._stripGaindB100Layer1
|
||||||
|
+ self._stripGaindB100Layer2
|
||||||
|
+ self._stripGaindB100Layer3
|
||||||
|
+ self._stripGaindB100Layer4
|
||||||
|
+ self._stripGaindB100Layer5
|
||||||
|
+ self._stripGaindB100Layer6
|
||||||
|
+ self._stripGaindB100Layer7
|
||||||
|
+ self._stripGaindB100Layer8
|
||||||
|
)
|
||||||
|
other_gains = (
|
||||||
|
other._stripGaindB100Layer1
|
||||||
|
+ other._stripGaindB100Layer2
|
||||||
|
+ other._stripGaindB100Layer3
|
||||||
|
+ other._stripGaindB100Layer4
|
||||||
|
+ other._stripGaindB100Layer5
|
||||||
|
+ other._stripGaindB100Layer6
|
||||||
|
+ other._stripGaindB100Layer7
|
||||||
|
+ other._stripGaindB100Layer8
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._stripState != other._stripState
|
||||||
|
or self._busState != other._busState
|
||||||
|
or self_gains != other_gains
|
||||||
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strip_levels(self) -> tuple[float, ...]:
|
||||||
|
"""Returns strip levels in dB"""
|
||||||
|
return tuple(
|
||||||
|
round(
|
||||||
|
int.from_bytes(self._inputLeveldB100[i : i + 2], 'little', signed=True)
|
||||||
|
* 0.01,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
for i in range(0, len(self._inputLeveldB100), 2)
|
||||||
|
)[: self._kind.num_strip_levels]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bus_levels(self) -> tuple[float, ...]:
|
||||||
|
"""Returns bus levels in dB"""
|
||||||
|
return tuple(
|
||||||
|
round(
|
||||||
|
int.from_bytes(self._outputLeveldB100[i : i + 2], 'little', signed=True)
|
||||||
|
* 0.01,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
for i in range(0, len(self._outputLeveldB100), 2)
|
||||||
|
)[: 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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def states(self) -> States:
|
||||||
|
"""returns States object with processed strip and bus channel states"""
|
||||||
|
return States(
|
||||||
|
strip=tuple(
|
||||||
|
ChannelState(self._stripState[i : i + 4]) for i in range(0, 32, 4)
|
||||||
|
),
|
||||||
|
bus=tuple(ChannelState(self._busState[i : i + 4]) for i in range(0, 32, 4)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gainlayers(self) -> tuple:
|
||||||
|
"""returns tuple of all strip gain layers as tuples"""
|
||||||
|
return tuple(
|
||||||
|
tuple(
|
||||||
|
round(
|
||||||
|
int.from_bytes(
|
||||||
|
getattr(self, f'_stripGaindB100Layer{layer}')[i : i + 2],
|
||||||
|
'little',
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
* 0.01,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
for i in range(0, 16, 2)
|
||||||
|
)
|
||||||
|
for layer in range(1, 9)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def busgain(self) -> tuple:
|
||||||
|
"""returns tuple of bus gains"""
|
||||||
|
return tuple(
|
||||||
|
round(
|
||||||
|
int.from_bytes(self._busGaindB100[i : i + 2], 'little', signed=True)
|
||||||
|
* 0.01,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
for i in range(0, 16, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
357
vban_cmd/packet/nbs1.py
Normal file
357
vban_cmd/packet/nbs1.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> int:
|
||||||
|
return int.from_bytes(self._mode, 'little')
|
||||||
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def karaoke(self) -> int:
|
||||||
|
return int.from_bytes(self._nKaraoke, 'little')
|
||||||
|
|
||||||
|
@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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def denoiser(self) -> DenoiserSettings:
|
||||||
|
return DenoiserSettings(
|
||||||
|
threshold=round(
|
||||||
|
int.from_bytes(self._DenoiserThreshold, 'little', signed=True) * 0.01, 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
),
|
||||||
|
)
|
||||||
124
vban_cmd/packet/ping0.py
Normal file
124
vban_cmd/packet/ping0.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
data.extend(payload.bit_type.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.bit_feature.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.bit_feature_ex.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.preferred_rate.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.min_rate.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.max_rate.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.color_rgb.to_bytes(4, 'little'))
|
||||||
|
data.extend(payload.version)
|
||||||
|
data.extend(payload.gps_position)
|
||||||
|
data.extend(payload.user_position)
|
||||||
|
data.extend(payload.lang_code)
|
||||||
|
data.extend(payload.reserved)
|
||||||
|
data.extend(payload.reserved_ex)
|
||||||
|
data.extend(payload.distant_ip)
|
||||||
|
data.extend(payload.distant_port.to_bytes(2, 'little'))
|
||||||
|
data.extend(payload.distant_reserved.to_bytes(2, 'little'))
|
||||||
|
data.extend(payload.device_name)
|
||||||
|
data.extend(payload.manufacturer_name)
|
||||||
|
data.extend(payload.application_name)
|
||||||
|
data.extend(payload.host_name)
|
||||||
|
data.extend(payload.user_name)
|
||||||
|
data.extend(payload.user_comment)
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
@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,10 +1,17 @@
|
|||||||
|
import abc
|
||||||
import time
|
import time
|
||||||
from abc import abstractmethod
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
|
from . import kinds
|
||||||
|
from .enums import NBS
|
||||||
from .iremote import IRemote
|
from .iremote import IRemote
|
||||||
from .kinds import kinds_all
|
from .meta import (
|
||||||
from .meta import channel_bool_prop, channel_label_prop, strip_output_prop
|
channel_bool_prop,
|
||||||
|
channel_label_prop,
|
||||||
|
send_prop,
|
||||||
|
strip_output_prop,
|
||||||
|
xy_prop,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Strip(IRemote):
|
class Strip(IRemote):
|
||||||
@@ -14,13 +21,13 @@ class Strip(IRemote):
|
|||||||
Defines concrete implementation for strip
|
Defines concrete implementation for strip
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abc.abstractmethod
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}]"
|
return f'strip[{self.index}]'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def limit(self) -> int:
|
def limit(self) -> int:
|
||||||
@@ -28,212 +35,268 @@ class Strip(IRemote):
|
|||||||
|
|
||||||
@limit.setter
|
@limit.setter
|
||||||
def limit(self, val: int):
|
def limit(self, val: int):
|
||||||
self.setter("limit", val)
|
self.setter('limit', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gain(self) -> float:
|
def gain(self) -> float:
|
||||||
val = self.getter("gain")
|
val = self.getter('gain')
|
||||||
if val is None:
|
if val is None:
|
||||||
val = self.gainlayer[0].gain
|
val = max(layer.gain for layer in self.gainlayer)
|
||||||
return round(val, 1)
|
return round(val, 1)
|
||||||
|
|
||||||
@gain.setter
|
@gain.setter
|
||||||
def gain(self, val: float):
|
def gain(self, val: float):
|
||||||
self.setter("gain", val)
|
self.setter('gain', val)
|
||||||
|
|
||||||
def fadeto(self, target: float, time_: int):
|
def fadeto(self, target: float, time_: int):
|
||||||
self.setter("FadeTo", f"({target}, {time_})")
|
self.setter('FadeTo', f'({target}, {time_})')
|
||||||
time.sleep(self._remote.DELAY)
|
time.sleep(self._remote.DELAY)
|
||||||
|
|
||||||
def fadeby(self, change: float, time_: int):
|
def fadeby(self, change: float, time_: int):
|
||||||
self.setter("FadeBy", f"({change}, {time_})")
|
self.setter('FadeBy', f'({change}, {time_})')
|
||||||
time.sleep(self._remote.DELAY)
|
time.sleep(self._remote.DELAY)
|
||||||
|
|
||||||
|
|
||||||
class PhysicalStrip(Strip):
|
class PhysicalStrip(Strip):
|
||||||
@classmethod
|
@classmethod
|
||||||
def make(cls, remote, index):
|
def make(cls, remote, index, is_phys):
|
||||||
|
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
|
||||||
return type(
|
return type(
|
||||||
f"PhysicalStrip{remote.kind}",
|
f'PhysicalStrip{remote.kind}',
|
||||||
(cls,),
|
(cls, EFFECTS_cls),
|
||||||
{
|
{
|
||||||
"comp": StripComp(remote, index),
|
'comp': StripComp(remote, index),
|
||||||
"gate": StripGate(remote, index),
|
'gate': StripGate(remote, index),
|
||||||
"denoiser": StripDenoiser(remote, index),
|
'denoiser': StripDenoiser(remote, index),
|
||||||
"eq": StripEQ(remote, index),
|
'eq': StripEQ.make(remote, index),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{type(self).__name__}{self.index}"
|
return f'{type(self).__name__}{self.index}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device(self):
|
def audibility(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].audibility.knob
|
||||||
|
|
||||||
@property
|
@audibility.setter
|
||||||
def sr(self):
|
def audibility(self, val: float):
|
||||||
return
|
self.setter('audibility', val)
|
||||||
|
|
||||||
|
|
||||||
class StripComp(IRemote):
|
class StripComp(IRemote):
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}].comp"
|
return f'strip[{self.index}].comp'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def knob(self) -> float:
|
def knob(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].audibility.comp
|
||||||
|
|
||||||
@knob.setter
|
@knob.setter
|
||||||
def knob(self, val: float):
|
def knob(self, val: float):
|
||||||
self.setter("", val)
|
self.setter('', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gainin(self) -> float:
|
def gainin(self) -> float:
|
||||||
return
|
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
|
@gainin.setter
|
||||||
def gainin(self, val: float):
|
def gainin(self, val: float):
|
||||||
self.setter("GainIn", val)
|
self.setter('GainIn', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ratio(self) -> float:
|
def ratio(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].compressor.ratio
|
||||||
|
|
||||||
@ratio.setter
|
@ratio.setter
|
||||||
def ratio(self, val: float):
|
def ratio(self, val: float):
|
||||||
self.setter("Ratio", val)
|
self.setter('Ratio', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def threshold(self) -> float:
|
def threshold(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].compressor.threshold
|
||||||
|
|
||||||
@threshold.setter
|
@threshold.setter
|
||||||
def threshold(self, val: float):
|
def threshold(self, val: float):
|
||||||
self.setter("Threshold", val)
|
self.setter('Threshold', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attack(self) -> float:
|
def attack(self) -> float:
|
||||||
return
|
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
|
@attack.setter
|
||||||
def attack(self, val: float):
|
def attack(self, val: float):
|
||||||
self.setter("Attack", val)
|
self.setter('Attack', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def release(self) -> float:
|
def release(self) -> float:
|
||||||
return
|
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
|
@release.setter
|
||||||
def release(self, val: float):
|
def release(self, val: float):
|
||||||
self.setter("Release", val)
|
self.setter('Release', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def knee(self) -> float:
|
def knee(self) -> float:
|
||||||
return
|
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
|
@knee.setter
|
||||||
def knee(self, val: float):
|
def knee(self, val: float):
|
||||||
self.setter("Knee", val)
|
self.setter('Knee', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gainout(self) -> float:
|
def gainout(self) -> float:
|
||||||
return
|
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
|
@gainout.setter
|
||||||
def gainout(self, val: float):
|
def gainout(self, val: float):
|
||||||
self.setter("GainOut", val)
|
self.setter('GainOut', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def makeup(self) -> bool:
|
def makeup(self) -> bool:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return False
|
||||||
|
return bool(self.public_packets[NBS.one].strips[self.index].compressor.makeup)
|
||||||
|
|
||||||
@makeup.setter
|
@makeup.setter
|
||||||
def makeup(self, val: bool):
|
def makeup(self, val: bool):
|
||||||
self.setter("makeup", 1 if val else 0)
|
self.setter('makeup', 1 if val else 0)
|
||||||
|
|
||||||
|
|
||||||
class StripGate(IRemote):
|
class StripGate(IRemote):
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}].gate"
|
return f'strip[{self.index}].gate'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def knob(self) -> float:
|
def knob(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].audibility.gate
|
||||||
|
|
||||||
@knob.setter
|
@knob.setter
|
||||||
def knob(self, val: float):
|
def knob(self, val: float):
|
||||||
self.setter("", val)
|
self.setter('', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def threshold(self) -> float:
|
def threshold(self) -> float:
|
||||||
return
|
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
|
@threshold.setter
|
||||||
def threshold(self, val: float):
|
def threshold(self, val: float):
|
||||||
self.setter("Threshold", val)
|
self.setter('Threshold', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def damping(self) -> float:
|
def damping(self) -> float:
|
||||||
return
|
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
|
@damping.setter
|
||||||
def damping(self, val: float):
|
def damping(self, val: float):
|
||||||
self.setter("Damping", val)
|
self.setter('Damping', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bpsidechain(self) -> int:
|
def bpsidechain(self) -> int:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].gate.bp_sidechain
|
||||||
|
|
||||||
@bpsidechain.setter
|
@bpsidechain.setter
|
||||||
def bpsidechain(self, val: int):
|
def bpsidechain(self, val: int):
|
||||||
self.setter("BPSidechain", val)
|
self.setter('BPSidechain', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attack(self) -> float:
|
def attack(self) -> float:
|
||||||
return
|
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
|
@attack.setter
|
||||||
def attack(self, val: float):
|
def attack(self, val: float):
|
||||||
self.setter("Attack", val)
|
self.setter('Attack', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hold(self) -> float:
|
def hold(self) -> float:
|
||||||
return
|
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
|
@hold.setter
|
||||||
def hold(self, val: float):
|
def hold(self, val: float):
|
||||||
self.setter("Hold", val)
|
self.setter('Hold', val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def release(self) -> float:
|
def release(self) -> float:
|
||||||
return
|
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
|
@release.setter
|
||||||
def release(self, val: float):
|
def release(self, val: float):
|
||||||
self.setter("Release", val)
|
self.setter('Release', val)
|
||||||
|
|
||||||
|
|
||||||
class StripDenoiser(IRemote):
|
class StripDenoiser(IRemote):
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}].denoiser"
|
return f'strip[{self.index}].denoiser'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def knob(self) -> float:
|
def knob(self) -> float:
|
||||||
return
|
if self.public_packets[NBS.one] is None:
|
||||||
|
return 0.0
|
||||||
|
return self.public_packets[NBS.one].strips[self.index].audibility.denoiser
|
||||||
|
|
||||||
@knob.setter
|
@knob.setter
|
||||||
def knob(self, val: float):
|
def knob(self, val: float):
|
||||||
self.setter("", val)
|
self.setter('', val)
|
||||||
|
|
||||||
|
|
||||||
class StripEQ(IRemote):
|
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
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}].eq"
|
return f'strip[{self.index}].eq'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def on(self):
|
def on(self):
|
||||||
@@ -241,7 +304,7 @@ class StripEQ(IRemote):
|
|||||||
|
|
||||||
@on.setter
|
@on.setter
|
||||||
def on(self, val: bool):
|
def on(self, val: bool):
|
||||||
self.setter("on", 1 if val else 0)
|
self.setter('on', 1 if val else 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ab(self):
|
def ab(self):
|
||||||
@@ -249,30 +312,214 @@ class StripEQ(IRemote):
|
|||||||
|
|
||||||
@ab.setter
|
@ab.setter
|
||||||
def ab(self, val: bool):
|
def ab(self, val: bool):
|
||||||
self.setter("ab", 1 if val else 0)
|
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):
|
class VirtualStrip(Strip):
|
||||||
def __str__(self):
|
@classmethod
|
||||||
return f"{type(self).__name__}{self.index}"
|
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
|
mono = mc
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def k(self) -> int:
|
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
|
@k.setter
|
||||||
def k(self, val: int):
|
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):
|
def appgain(self, name: str, gain: float):
|
||||||
self.setter("AppGain", f'("{name}", {gain})')
|
self.setter('AppGain', f'("{name}", {gain})')
|
||||||
|
|
||||||
def appmute(self, name: str, mute: bool = None):
|
def appmute(self, name: str, mute: bool = None):
|
||||||
self.setter("AppMute", f'("{name}", {1 if mute else 0})')
|
self.setter('AppMute', f'("{name}", {1 if mute else 0})')
|
||||||
|
|
||||||
|
|
||||||
class StripLevel(IRemote):
|
class StripLevel(IRemote):
|
||||||
@@ -293,26 +540,15 @@ class StripLevel(IRemote):
|
|||||||
def getter(self):
|
def getter(self):
|
||||||
"""Returns a tuple of level values for the channel."""
|
"""Returns a tuple of level values for the channel."""
|
||||||
|
|
||||||
def fget(i):
|
if not self._remote.stopped() and self._remote.event.ldirty:
|
||||||
return round((((1 << 16) - 1) - i) * -0.01, 1)
|
return self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
|
||||||
|
return self.public_packets[NBS.zero].levels.strip[
|
||||||
if self._remote.running and self._remote.event.ldirty:
|
self.range[0] : self.range[-1]
|
||||||
return tuple(
|
]
|
||||||
fget(i)
|
|
||||||
for i in self._remote.cache["strip_level"][
|
|
||||||
self.range[0] : self.range[-1]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return tuple(
|
|
||||||
fget(i)
|
|
||||||
for i in self._remote._get_levels(self.public_packet)[0][
|
|
||||||
self.range[0] : self.range[-1]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}]"
|
return f'strip[{self.index}]'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prefader(self) -> tuple:
|
def prefader(self) -> tuple:
|
||||||
@@ -345,31 +581,28 @@ class GainLayer(IRemote):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
return f"Strip[{self.index}]"
|
return f'strip[{self.index}]'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def gain(self) -> float:
|
def gain(self) -> float:
|
||||||
def fget():
|
val = self.getter(f'GainLayer[{self._i}]')
|
||||||
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index]
|
if val:
|
||||||
if 0 <= val <= 1200:
|
return round(val, 2)
|
||||||
return val * 0.01
|
else:
|
||||||
return (((1 << 16) - 1) - val) * -0.01
|
return self.public_packets[NBS.zero].gainlayers[self._i][self.index]
|
||||||
|
|
||||||
val = self.getter(f"GainLayer[{self._i}]")
|
|
||||||
return round(val if val else fget(), 1)
|
|
||||||
|
|
||||||
@gain.setter
|
@gain.setter
|
||||||
def gain(self, val: float):
|
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):
|
def _make_gainlayer_mixin(remote, index):
|
||||||
"""Creates a GainLayer mixin"""
|
"""Creates a GainLayer mixin"""
|
||||||
return type(
|
return type(
|
||||||
f"GainlayerMixin",
|
'GainlayerMixin',
|
||||||
(),
|
(),
|
||||||
{
|
{
|
||||||
"gainlayer": tuple(
|
'gainlayer': tuple(
|
||||||
GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
|
GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -379,24 +612,78 @@ def _make_gainlayer_mixin(remote, index):
|
|||||||
def _make_channelout_mixin(kind):
|
def _make_channelout_mixin(kind):
|
||||||
"""Creates a channel out property mixin"""
|
"""Creates a channel out property mixin"""
|
||||||
return type(
|
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 = {
|
_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]:
|
def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]:
|
||||||
"""
|
"""
|
||||||
Factory method for strips
|
Factory method for strips
|
||||||
@@ -405,17 +692,21 @@ def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip
|
|||||||
|
|
||||||
Returns a physical or virtual strip subclass
|
Returns a physical or virtual strip subclass
|
||||||
"""
|
"""
|
||||||
STRIP_cls = PhysicalStrip.make(remote, i) 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]
|
CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
|
||||||
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
|
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
|
||||||
|
|
||||||
return type(
|
return type(
|
||||||
f"{STRIP_cls.__name__}{remote.kind}",
|
f'{STRIP_cls.__name__}{remote.kind}',
|
||||||
(STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls),
|
(STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls),
|
||||||
{
|
{
|
||||||
"levels": StripLevel(remote, i),
|
'levels': StripLevel(remote, i),
|
||||||
**{param: channel_bool_prop(param) for param in ["mono", "solo", "mute"]},
|
**{param: channel_bool_prop(param) for param in ['mono', 'solo', 'mute']},
|
||||||
"label": channel_label_prop(),
|
'label': channel_label_prop(),
|
||||||
},
|
},
|
||||||
)(remote, i)
|
)(remote, i)
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ class Subject:
|
|||||||
"""run callbacks on update"""
|
"""run callbacks on update"""
|
||||||
|
|
||||||
for o in self._observers:
|
for o in self._observers:
|
||||||
if hasattr(o, "on_update"):
|
if hasattr(o, 'on_update'):
|
||||||
o.on_update(event)
|
o.on_update(event)
|
||||||
else:
|
else:
|
||||||
if o.__name__ == f"on_{event}":
|
if o.__name__ == f'on_{event}':
|
||||||
o()
|
o()
|
||||||
|
|
||||||
def add(self, observer):
|
def add(self, observer):
|
||||||
@@ -34,15 +34,15 @@ class Subject:
|
|||||||
for o in iterator:
|
for o in iterator:
|
||||||
if o not in self._observers:
|
if o not in self._observers:
|
||||||
self._observers.append(o)
|
self._observers.append(o)
|
||||||
self.logger.info(f"{o} added to event observers")
|
self.logger.info(f'{o} added to event observers')
|
||||||
else:
|
else:
|
||||||
self.logger.error(f"Failed to add {o} to event observers")
|
self.logger.error(f'Failed to add {o} to event observers')
|
||||||
except TypeError:
|
except TypeError:
|
||||||
if observer not in self._observers:
|
if observer not in self._observers:
|
||||||
self._observers.append(observer)
|
self._observers.append(observer)
|
||||||
self.logger.info(f"{observer} added to event observers")
|
self.logger.info(f'{observer} added to event observers')
|
||||||
else:
|
else:
|
||||||
self.logger.error(f"Failed to add {observer} to event observers")
|
self.logger.error(f'Failed to add {observer} to event observers')
|
||||||
|
|
||||||
register = add
|
register = add
|
||||||
|
|
||||||
@@ -54,15 +54,15 @@ class Subject:
|
|||||||
for o in iterator:
|
for o in iterator:
|
||||||
try:
|
try:
|
||||||
self._observers.remove(o)
|
self._observers.remove(o)
|
||||||
self.logger.info(f"{o} removed from event observers")
|
self.logger.info(f'{o} removed from event observers')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.error(f"Failed to remove {o} from event observers")
|
self.logger.error(f'Failed to remove {o} from event observers')
|
||||||
except TypeError:
|
except TypeError:
|
||||||
try:
|
try:
|
||||||
self._observers.remove(observer)
|
self._observers.remove(observer)
|
||||||
self.logger.info(f"{observer} removed from event observers")
|
self.logger.info(f'{observer} removed from event observers')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.logger.error(f"Failed to remove {observer} from event observers")
|
self.logger.error(f'Failed to remove {observer} from event observers')
|
||||||
|
|
||||||
deregister = remove
|
deregister = remove
|
||||||
|
|
||||||
|
|||||||
148
vban_cmd/util.py
148
vban_cmd/util.py
@@ -1,15 +1,84 @@
|
|||||||
from enum import IntEnum
|
import socket
|
||||||
|
import time
|
||||||
from typing import Iterator
|
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):
|
def cache_bool(func, param):
|
||||||
"""Check cache for a bool prop"""
|
"""Check cache for a bool prop"""
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
self, *rem = args
|
self, *rem = args
|
||||||
cmd = f"{self.identifier}.{param}"
|
if self._cmd(param) in self._remote.cache:
|
||||||
if cmd in self._remote.cache:
|
return self._remote.cache.pop(self._cmd(param)) == 1
|
||||||
return self._remote.cache.pop(cmd) == 1
|
if self._remote.sync:
|
||||||
|
self._remote.clear_dirty()
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return 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:
|
if self._remote.sync:
|
||||||
self._remote.clear_dirty()
|
self._remote.clear_dirty()
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
@@ -22,9 +91,22 @@ def cache_string(func, param):
|
|||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
self, *rem = args
|
self, *rem = args
|
||||||
cmd = f"{self.identifier}.{param}"
|
if self._cmd(param) in self._remote.cache:
|
||||||
if cmd in self._remote.cache:
|
return self._remote.cache.pop(self._cmd(param)).strip('"')
|
||||||
return self._remote.cache.pop(cmd)
|
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:
|
if self._remote.sync:
|
||||||
self._remote.clear_dirty()
|
self._remote.clear_dirty()
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
@@ -38,39 +120,39 @@ def depth(d):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def script(func):
|
|
||||||
"""Convert dictionary to script"""
|
|
||||||
|
|
||||||
def wrapper(*args):
|
|
||||||
remote, script = args
|
|
||||||
if isinstance(script, dict):
|
|
||||||
params = ""
|
|
||||||
for key, val in script.items():
|
|
||||||
obj, m2, *rem = key.split("-")
|
|
||||||
index = int(m2) if m2.isnumeric() else int(*rem)
|
|
||||||
params += ";".join(
|
|
||||||
f"{obj}{f'.{m2}stream' if not m2.isnumeric() else ''}[{index}].{k}={int(v) if isinstance(v, bool) else v}"
|
|
||||||
for k, v in val.items()
|
|
||||||
)
|
|
||||||
params += ";"
|
|
||||||
script = params
|
|
||||||
return func(remote, script)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
|
def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
|
||||||
"""
|
"""
|
||||||
Generator function, accepts two tuples.
|
Generator function, accepts two tuples of dB values.
|
||||||
|
|
||||||
Evaluates equality of each member in both tuples.
|
Evaluates equality of each member in both tuples.
|
||||||
|
Only ignores changes when levels are very quiet (below -72 dB).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for a, b in zip(t0, t1):
|
for a, b in zip(t0, t1):
|
||||||
if ((1 << 16) - 1) - b <= 7200:
|
# If both values are very quiet (below -72dB), ignore small changes
|
||||||
yield a == b
|
if a <= -72.0 and b <= -72.0:
|
||||||
|
yield a == b # Both quiet, check if they're equal
|
||||||
else:
|
else:
|
||||||
yield True
|
yield a != b # At least one has significant level, detect changes
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
|
import abc
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from abc import ABCMeta, abstractmethod
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
from typing import Iterable, Optional, Union
|
from typing import Mapping, Union
|
||||||
|
|
||||||
from .error import VBANCMDError
|
from .enums import NBS
|
||||||
|
from .error import VBANCMDConnectionError, VBANCMDError
|
||||||
from .event import Event
|
from .event import Event
|
||||||
from .packet import RequestHeader
|
from .packet.headers import (
|
||||||
|
VbanMatrixResponseHeader,
|
||||||
|
VbanPongHeader,
|
||||||
|
VbanRTRequestHeader,
|
||||||
|
)
|
||||||
|
from .packet.ping0 import VbanPing0Payload, VbanServerType
|
||||||
from .subject import Subject
|
from .subject import Subject
|
||||||
from .util import Socket, script
|
from .util import bump_framecounter, deep_merge, pong_timeout, ratelimit
|
||||||
from .worker import Producer, Subscriber, Updater
|
from .worker import Producer, Subscriber, Updater
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class VbanCmd(metaclass=ABCMeta):
|
class VbanCmd(abc.ABC):
|
||||||
"""Base class responsible for communicating with the VBAN RT Packet Service"""
|
"""Abstract Base Class for Voicemeeter VBAN Command Interfaces"""
|
||||||
|
|
||||||
DELAY = 0.001
|
DELAY = 0.001
|
||||||
# fmt: off
|
# fmt: off
|
||||||
@@ -30,115 +37,214 @@ class VbanCmd(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
self.event = Event({k: kwargs.pop(k) for k in ("pdirty", "ldirty")})
|
self.event = Event({k: kwargs.pop(k) for k in ('pdirty', 'ldirty')})
|
||||||
if not kwargs["ip"]:
|
if not kwargs['host']:
|
||||||
kwargs |= self._conn_from_toml()
|
kwargs |= self._conn_from_toml()
|
||||||
for attr, val in kwargs.items():
|
for attr, val in kwargs.items():
|
||||||
setattr(self, attr, val)
|
setattr(self, attr, val)
|
||||||
|
|
||||||
self.packet_request = RequestHeader(
|
self._framecounter = 0
|
||||||
name=self.streamname,
|
self._framecounter_lock = threading.Lock()
|
||||||
bps_index=self.BPS_OPTS.index(self.bps),
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
channel=self.channel,
|
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
)
|
self.sock.settimeout(self.timeout)
|
||||||
self.socks = tuple(
|
|
||||||
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in Socket
|
|
||||||
)
|
|
||||||
self.subject = self.observer = Subject()
|
self.subject = self.observer = Subject()
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
self._pdirty = False
|
self._pdirty = False
|
||||||
self._ldirty = False
|
self._ldirty = False
|
||||||
|
self.stop_event = None
|
||||||
|
self.producer = None
|
||||||
|
self._last_script_request_time = 0
|
||||||
|
|
||||||
@abstractmethod
|
@abc.abstractmethod
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Ensure subclasses override str magic method"""
|
"""Ensure subclasses override str magic method"""
|
||||||
pass
|
|
||||||
|
|
||||||
def _conn_from_toml(self) -> dict:
|
def _conn_from_toml(self) -> dict:
|
||||||
try:
|
try:
|
||||||
import tomllib
|
import tomllib
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
import tomli as tomllib
|
import tomli as tomllib # type: ignore[import]
|
||||||
|
|
||||||
def get_filepath():
|
def get_filepath():
|
||||||
filepaths = [
|
for pn in (
|
||||||
Path.cwd() / "vban.toml",
|
Path.cwd() / 'vban.toml',
|
||||||
Path.home() / ".config" / "vban-cmd" / "vban.toml",
|
Path.cwd() / 'configs' / 'vban.toml',
|
||||||
Path.home() / "Documents" / "Voicemeeter" / "vban.toml",
|
Path.home() / '.config' / 'vban-cmd' / 'vban.toml',
|
||||||
]
|
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / 'vban.toml',
|
||||||
for filepath in filepaths:
|
):
|
||||||
if filepath.exists():
|
if pn.exists():
|
||||||
return filepath
|
return pn
|
||||||
|
|
||||||
if filepath := get_filepath():
|
if not (filepath := get_filepath()):
|
||||||
with open(filepath, "rb") as f:
|
raise VBANCMDError('no ip provided and no vban.toml located.')
|
||||||
conn = tomllib.load(f)
|
try:
|
||||||
assert (
|
with open(filepath, 'rb') as f:
|
||||||
"ip" in conn["connection"]
|
return tomllib.load(f)['connection']
|
||||||
), "please provide ip, by kwarg or config"
|
except tomllib.TomlDecodeError as e:
|
||||||
return conn["connection"]
|
raise VBANCMDError(f'Error decoding {filepath}: {e}') from e
|
||||||
else:
|
|
||||||
raise VBANCMDError("no ip provided and no vban.toml located.")
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.login()
|
self.login()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def login(self):
|
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||||
"""Starts the subscriber and updater threads"""
|
self.logout()
|
||||||
self.running = True
|
|
||||||
self.event.info()
|
|
||||||
|
|
||||||
self.subscriber = Subscriber(self)
|
def login(self) -> None:
|
||||||
self.subscriber.start()
|
"""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()
|
||||||
|
|
||||||
queue = Queue()
|
if not self.disable_rt_listeners:
|
||||||
self.updater = Updater(self, queue)
|
self.event.info()
|
||||||
self.updater.start()
|
|
||||||
self.producer = Producer(self, queue)
|
|
||||||
self.producer.start()
|
|
||||||
|
|
||||||
self.logger.info(f"{type(self).__name__}: Successfully logged into {self}")
|
self.stop_event = threading.Event()
|
||||||
|
self.stop_event.clear()
|
||||||
|
self.subscriber = Subscriber(self, self.stop_event)
|
||||||
|
self.subscriber.start()
|
||||||
|
|
||||||
def _set_rt(
|
queue = Queue()
|
||||||
self,
|
self.updater = Updater(self, queue)
|
||||||
id_: str,
|
self.updater.start()
|
||||||
param: Optional[str] = None,
|
self.producer = Producer(self, queue, self.stop_event)
|
||||||
val: Optional[Union[int, float]] = None,
|
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."""
|
"""Sends a string request command over a network."""
|
||||||
cmd = f"{id_}={val};" if not param else f"{id_}.{param}={val};"
|
self._send_request(f'{cmd}={val};')
|
||||||
self.socks[Socket.request].sendto(
|
self.cache[cmd] = val
|
||||||
self.packet_request.header + cmd.encode(),
|
|
||||||
(socket.gethostbyname(self.ip), self.port),
|
|
||||||
)
|
|
||||||
self.packet_request.framecounter = (
|
|
||||||
int.from_bytes(self.packet_request.framecounter, "little") + 1
|
|
||||||
).to_bytes(4, "little")
|
|
||||||
if param:
|
|
||||||
self.cache[f"{id_}.{param}"] = val
|
|
||||||
|
|
||||||
@script
|
@ratelimit
|
||||||
def sendtext(self, cmd):
|
def sendtext(self, script) -> str | None:
|
||||||
"""Sends a multiple parameter string over a network."""
|
"""Sends a multiple parameter string over a network."""
|
||||||
self.socks[Socket.request].sendto(
|
self._send_request(script)
|
||||||
self.packet_request.header + cmd.encode(),
|
self.logger.debug(f'sendtext: {script}')
|
||||||
(socket.gethostbyname(self.ip), self.port),
|
|
||||||
)
|
if self.disable_rt_listeners and script.endswith(('?', '?;')):
|
||||||
self.packet_request.framecounter = (
|
try:
|
||||||
int.from_bytes(self.packet_request.framecounter, "little") + 1
|
data, _ = self.sock.recvfrom(2048)
|
||||||
).to_bytes(4, "little")
|
return VbanMatrixResponseHeader.extract_payload(data)
|
||||||
time.sleep(self.DELAY)
|
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
|
@property
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
"""Returns the type of Voicemeeter installation."""
|
"""Returns the type of Voicemeeter installation."""
|
||||||
return self.public_packet.voicemeetertype
|
return self.public_packets[NBS.zero].voicemeetertype
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def version(self) -> str:
|
def version(self) -> str:
|
||||||
"""Returns Voicemeeter's version as a string"""
|
"""Returns Voicemeeter's version as a string"""
|
||||||
return "{0}.{1}.{2}.{3}".format(*self.public_packet.voicemeeterversion)
|
return '{0}.{1}.{2}.{3}'.format(
|
||||||
|
*self.public_packets[NBS.zero].voicemeeterversion
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pdirty(self):
|
def pdirty(self):
|
||||||
@@ -151,59 +257,58 @@ class VbanCmd(metaclass=ABCMeta):
|
|||||||
return self._ldirty
|
return self._ldirty
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_packet(self):
|
def public_packets(self):
|
||||||
return self._public_packet
|
return self._public_packets
|
||||||
|
|
||||||
def clear_dirty(self):
|
def clear_dirty(self) -> None:
|
||||||
while self.pdirty:
|
while self.pdirty:
|
||||||
time.sleep(self.DELAY)
|
time.sleep(self.DELAY)
|
||||||
|
|
||||||
def _get_levels(self, packet) -> Iterable:
|
def apply(self, data: Mapping):
|
||||||
"""
|
"""Set all parameters of a dict"""
|
||||||
returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
|
|
||||||
|
|
||||||
strip levels in PREFADER mode.
|
def target(key):
|
||||||
"""
|
match key.split('-'):
|
||||||
return (
|
case ['strip' | 'bus' as kls, index] if index.isnumeric():
|
||||||
packet.inputlevels,
|
target = getattr(self, kls)
|
||||||
packet.outputlevels,
|
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)]
|
||||||
|
|
||||||
def apply(self, data: dict):
|
for key, di in data.items():
|
||||||
"""
|
target(key).apply(di)
|
||||||
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)
|
|
||||||
|
|
||||||
self._script = str()
|
|
||||||
[param(key).apply(datum).then_wait() for key, datum in data.items()]
|
|
||||||
|
|
||||||
def apply_config(self, name):
|
def apply_config(self, name):
|
||||||
"""applies a config from memory"""
|
"""applies a config from memory"""
|
||||||
error_msg = (
|
ERR_MSG = (
|
||||||
f"No config with name '{name}' is loaded into memory",
|
f"No config with name '{name}' is loaded into memory",
|
||||||
f"Known configs: {list(self.configs.keys())}",
|
f'Known configs: {list(self.configs.keys())}',
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.apply(self.configs[name])
|
config = self.configs[name]
|
||||||
self.logger.info(f"Profile '{name}' applied!")
|
except KeyError as e:
|
||||||
except KeyError:
|
self.logger.error(('\n').join(ERR_MSG))
|
||||||
self.logger.error(("\n").join(error_msg))
|
raise VBANCMDError(('\n').join(ERR_MSG)) from e
|
||||||
|
|
||||||
def logout(self):
|
if 'extends' in config:
|
||||||
self.running = False
|
extended = config['extends']
|
||||||
time.sleep(0.2)
|
config = {
|
||||||
[sock.close() for sock in self.socks]
|
k: v
|
||||||
self.logger.info(f"{type(self).__name__}: Successfully logged out of {self}")
|
for k, v in deep_merge(self.configs[extended], config)
|
||||||
|
if k not in ('extends')
|
||||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
}
|
||||||
self.logout()
|
self.logger.debug(
|
||||||
|
f"profile '{name}' extends '{extended}', profiles merged.."
|
||||||
|
)
|
||||||
|
self.apply(config)
|
||||||
|
self.logger.info(f"Profile '{name}' applied!")
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
from .enums import NBS
|
||||||
from .error import VBANCMDConnectionError
|
from .error import VBANCMDConnectionError
|
||||||
from .packet import HEADER_SIZE, SubscribeHeader, VbanRtPacket, VbanRtPacketHeader
|
from .packet.headers import (
|
||||||
from .util import Socket
|
HEADER_SIZE,
|
||||||
|
VbanRTPacket,
|
||||||
|
VbanRTResponseHeader,
|
||||||
|
VbanRTSubscribeHeader,
|
||||||
|
)
|
||||||
|
from .packet.nbs0 import VbanRTPacketNBS0
|
||||||
|
from .packet.nbs1 import VbanRTPacketNBS1
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -14,114 +19,110 @@ logger = logging.getLogger(__name__)
|
|||||||
class Subscriber(threading.Thread):
|
class Subscriber(threading.Thread):
|
||||||
"""fire a subscription packet every 10 seconds"""
|
"""fire a subscription packet every 10 seconds"""
|
||||||
|
|
||||||
def __init__(self, remote):
|
def __init__(self, remote, stop_event):
|
||||||
super().__init__(name="subscriber", daemon=True)
|
super().__init__(name='subscriber', daemon=False)
|
||||||
self._remote = remote
|
self._remote = remote
|
||||||
|
self.stop_event = stop_event
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
self.packet = SubscribeHeader()
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while self._remote.running:
|
while not self.stopped():
|
||||||
try:
|
for nbs in NBS:
|
||||||
self._remote.socks[Socket.register].sendto(
|
sub_packet = VbanRTSubscribeHeader().to_bytes(
|
||||||
self.packet.header,
|
nbs, self._remote._get_next_framecounter()
|
||||||
(socket.gethostbyname(self._remote.ip), self._remote.port),
|
|
||||||
)
|
)
|
||||||
self.packet.framecounter = (
|
self._remote.sock.sendto(
|
||||||
int.from_bytes(self.packet.framecounter, "little") + 1
|
sub_packet, (self._remote.host, self._remote.port)
|
||||||
).to_bytes(4, "little")
|
)
|
||||||
time.sleep(10)
|
|
||||||
except socket.gaierror as e:
|
self.wait_until_stopped(10)
|
||||||
self.logger.exception(f"{type(e).__name__}: {e}")
|
self.logger.debug(f'terminating {self.name} thread')
|
||||||
raise VBANCMDConnectionError(
|
|
||||||
f"unable to resolve hostname {self._remote.ip}"
|
def stopped(self):
|
||||||
) from e
|
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):
|
class Producer(threading.Thread):
|
||||||
"""Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
|
"""Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
|
||||||
|
|
||||||
def __init__(self, remote, queue):
|
def __init__(self, remote, queue, stop_event):
|
||||||
super().__init__(name="producer", daemon=True)
|
super().__init__(name='producer', daemon=False)
|
||||||
self._remote = remote
|
self._remote = remote
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
self.stop_event = stop_event
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
self.packet_expected = VbanRtPacketHeader()
|
self._remote._public_packets = [None] * (max(NBS) + 1)
|
||||||
self._remote._public_packet = self._get_rt()
|
_pp = self._get_rt()
|
||||||
|
self._remote._public_packets[_pp.nbs] = _pp
|
||||||
(
|
(
|
||||||
self._remote.cache["strip_level"],
|
self._remote.cache['strip_level'],
|
||||||
self._remote.cache["bus_level"],
|
self._remote.cache['bus_level'],
|
||||||
) = self._remote._get_levels(self._remote.public_packet)
|
) = self._remote.public_packets[NBS.zero].levels
|
||||||
|
|
||||||
def _get_rt(self) -> VbanRtPacket:
|
def _get_rt(self) -> VbanRTPacket:
|
||||||
"""Attempt to fetch data packet until a valid one found"""
|
"""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
|
||||||
|
|
||||||
def fget():
|
try:
|
||||||
data = None
|
header = VbanRTResponseHeader.from_bytes(data[:HEADER_SIZE])
|
||||||
while not data:
|
except ValueError as e:
|
||||||
data = self._fetch_rt_packet()
|
self.logger.debug(f'Error parsing response packet: {e}')
|
||||||
time.sleep(self._remote.DELAY)
|
continue
|
||||||
return data
|
|
||||||
|
|
||||||
return fget()
|
match header.format_nbs:
|
||||||
|
case NBS.zero:
|
||||||
def _fetch_rt_packet(self) -> Optional[VbanRtPacket]:
|
return VbanRTPacketNBS0.from_bytes(
|
||||||
try:
|
nbs=NBS.zero, kind=self._remote.kind, data=data
|
||||||
data, _ = self._remote.socks[Socket.response].recvfrom(2048)
|
|
||||||
# check for packet data
|
|
||||||
if len(data) > HEADER_SIZE:
|
|
||||||
# check if packet is of type rt packet response
|
|
||||||
if self.packet_expected.header == data[: HEADER_SIZE - 4]:
|
|
||||||
return VbanRtPacket(
|
|
||||||
_kind=self._remote.kind,
|
|
||||||
_voicemeeterType=data[28:29],
|
|
||||||
_reserved=data[29:30],
|
|
||||||
_buffersize=data[30:32],
|
|
||||||
_voicemeeterVersion=data[32:36],
|
|
||||||
_optionBits=data[36:40],
|
|
||||||
_samplerate=data[40:44],
|
|
||||||
_inputLeveldB100=data[44:112],
|
|
||||||
_outputLeveldB100=data[112:240],
|
|
||||||
_TransportBit=data[240:244],
|
|
||||||
_stripState=data[244:276],
|
|
||||||
_busState=data[276:308],
|
|
||||||
_stripGaindB100Layer1=data[308:324],
|
|
||||||
_stripGaindB100Layer2=data[324:340],
|
|
||||||
_stripGaindB100Layer3=data[340:356],
|
|
||||||
_stripGaindB100Layer4=data[356:372],
|
|
||||||
_stripGaindB100Layer5=data[372:388],
|
|
||||||
_stripGaindB100Layer6=data[388:404],
|
|
||||||
_stripGaindB100Layer7=data[404:420],
|
|
||||||
_stripGaindB100Layer8=data[420:436],
|
|
||||||
_busGaindB100=data[436:452],
|
|
||||||
_stripLabelUTF8c60=data[452:932],
|
|
||||||
_busLabelUTF8c60=data[932:1412],
|
|
||||||
)
|
)
|
||||||
except TimeoutError as e:
|
|
||||||
self.logger.exception(f"{type(e).__name__}: {e}")
|
case NBS.one:
|
||||||
raise VBANCMDConnectionError(
|
return VbanRTPacketNBS1.from_bytes(
|
||||||
f"timeout waiting for RtPacket from {self._remote.ip}"
|
nbs=NBS.one, kind=self._remote.kind, data=data
|
||||||
) from e
|
)
|
||||||
|
|
||||||
|
def stopped(self):
|
||||||
|
return self.stop_event.is_set()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while self._remote.running:
|
while not self.stopped():
|
||||||
|
pdirty = ldirty = False
|
||||||
_pp = self._get_rt()
|
_pp = self._get_rt()
|
||||||
pdirty = _pp.pdirty(self._remote.public_packet)
|
match _pp.nbs:
|
||||||
ldirty = _pp.ldirty(
|
case NBS.zero:
|
||||||
self._remote.cache["strip_level"], self._remote.cache["bus_level"]
|
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:
|
if pdirty or ldirty:
|
||||||
self._remote._public_packet = _pp
|
self._remote._public_packets[_pp.nbs] = _pp
|
||||||
self._remote._pdirty = pdirty
|
self._remote._pdirty = pdirty
|
||||||
self._remote._ldirty = ldirty
|
self._remote._ldirty = ldirty
|
||||||
|
|
||||||
if self._remote.event.pdirty:
|
if self._remote.event.pdirty:
|
||||||
self.queue.put("pdirty")
|
self.queue.put('pdirty')
|
||||||
if self._remote.event.ldirty:
|
if self._remote.event.ldirty:
|
||||||
self.queue.put("ldirty")
|
self.queue.put('ldirty')
|
||||||
time.sleep(self._remote.ratelimit)
|
self.logger.debug(f'terminating {self.name} thread')
|
||||||
self.logger.debug(f"terminating {self.name} thread")
|
|
||||||
self.queue.put(None)
|
self.queue.put(None)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,17 +134,12 @@ class Updater(threading.Thread):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, remote, queue):
|
def __init__(self, remote, queue):
|
||||||
super().__init__(name="updater", daemon=True)
|
super().__init__(name='updater', daemon=True)
|
||||||
self._remote = remote
|
self._remote = remote
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.logger = logger.getChild(self.__class__.__name__)
|
self.logger = logger.getChild(self.__class__.__name__)
|
||||||
self._remote.socks[Socket.response].settimeout(self._remote.timeout)
|
self._remote._strip_comp = [False] * (self._remote.kind.num_strip_levels)
|
||||||
self._remote.socks[Socket.response].bind(
|
self._remote._bus_comp = [False] * (self._remote.kind.num_bus_levels)
|
||||||
(socket.gethostbyname(socket.gethostname()), self._remote.port)
|
|
||||||
)
|
|
||||||
p_in, v_in = self._remote.kind.ins
|
|
||||||
self._remote._strip_comp = [False] * (2 * p_in + 8 * v_in)
|
|
||||||
self._remote._bus_comp = [False] * (self._remote.kind.num_bus * 8)
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
@@ -151,24 +147,17 @@ class Updater(threading.Thread):
|
|||||||
|
|
||||||
Generate _strip_comp, _bus_comp and update level cache if ldirty.
|
Generate _strip_comp, _bus_comp and update level cache if ldirty.
|
||||||
"""
|
"""
|
||||||
while True:
|
while event := self.queue.get():
|
||||||
event = self.queue.get()
|
if event == 'pdirty' and self._remote.pdirty:
|
||||||
if event is None:
|
|
||||||
self.logger.debug(f"terminating {self.name} thread")
|
|
||||||
break
|
|
||||||
|
|
||||||
if event == "pdirty" and self._remote.pdirty:
|
|
||||||
self._remote.subject.notify(event)
|
self._remote.subject.notify(event)
|
||||||
elif event == "ldirty" and self._remote.ldirty:
|
elif event == 'ldirty' and self._remote.ldirty:
|
||||||
self._remote._strip_comp, self._remote._bus_comp = (
|
self._remote._strip_comp, self._remote._bus_comp = (
|
||||||
self._remote._public_packet._strip_comp,
|
self._remote._public_packets[NBS.zero]._strip_comp,
|
||||||
self._remote._public_packet._bus_comp,
|
self._remote._public_packets[NBS.zero]._bus_comp,
|
||||||
)
|
)
|
||||||
(
|
(
|
||||||
self._remote.cache["strip_level"],
|
self._remote.cache['strip_level'],
|
||||||
self._remote.cache["bus_level"],
|
self._remote.cache['bus_level'],
|
||||||
) = (
|
) = self._remote.public_packets[NBS.zero].levels
|
||||||
self._remote._public_packet.inputlevels,
|
|
||||||
self._remote._public_packet.outputlevels,
|
|
||||||
)
|
|
||||||
self._remote.subject.notify(event)
|
self._remote.subject.notify(event)
|
||||||
|
self.logger.debug(f'terminating {self.name} thread')
|
||||||
|
|||||||
Reference in New Issue
Block a user