mirror of
https://github.com/onyx-and-iris/vban-cmd-python.git
synced 2026-04-07 08:03:30 +00:00
Compare commits
106 Commits
add-event-
...
v2.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 98ec9b715f | |||
| 5f7b62a0e0 | |||
| d1bcbfed6f | |||
| ab80bbf226 | |||
| ad58852a77 | |||
| 5363584940 | |||
| 9f43ee18d3 | |||
| 3cde874a3c | |||
| 3d01321be3 | |||
| 2dd52a7258 | |||
| 28cbef5ef6 | |||
| 5b3b35fca3 | |||
| 7b3149a1e1 | |||
| 230d9f0eb3 | |||
| c9a505df0a | |||
| 3e3bec6d50 | |||
| 55b3125e10 | |||
| 7b3340042c | |||
| 6ea0859180 | |||
| 81ed963bea | |||
| 0b99b6a67f | |||
| 86d0aa91c3 | |||
| cf66ae252c | |||
| 42f6f29d1e | |||
| a210766b7b | |||
| 7d741d6e8b | |||
| 8be9d3cb7f | |||
| 23b99cb66b | |||
| 2fd7b8ad8b | |||
| c851cb5abe | |||
| dc681f50d0 | |||
| a0ec00652b | |||
| 69263c22f2 | |||
| ad2cfeaae6 | |||
| 1123fe6432 | |||
| 3c3e415d7e | |||
| 8cfeb45fcb | |||
| 10b38b3fcc | |||
| ff5ac193c8 | |||
| 2f3cd0e07f | |||
| d689b3a301 | |||
| a8ef82166c | |||
| 79f06ecc79 | |||
| b291c3a477 | |||
| c335d35b9f | |||
| 911d2f64a6 | |||
| e58d6c7242 | |||
| 870a95b41e | |||
| 59880bf582 | |||
| cc58d1f081 | |||
| e37dea38b3 | |||
| 7f3b0ac7c9 | |||
| 0512fac710 | |||
| d439da725c | |||
| 45ffed9f63 | |||
| 14f79d1388 | |||
| b45bd38706 | |||
| 312b5c5842 | |||
| ed8e281f7f | |||
| efdcfce387 | |||
| ad88286509 | |||
| ecbdd2778f | |||
| 1babf85a89 | |||
| fbd1d54f9b | |||
| 96e9d6f4fd | |||
| 51394c0076 | |||
| 91feccc509 | |||
| c9c365ac54 | |||
| 1742ff839e | |||
| 5299d9ec6b | |||
| bc2cd3e7a5 | |||
| af68c423a6 | |||
| 16df0d559e | |||
| dad5ee9e9d | |||
|
|
694e1036de | ||
| 8436634371 | |||
| 074ba4fe77 | |||
|
|
2b4e64ed76 | ||
| 21df4998a2 | |||
| 7bff293820 | |||
| c8d0a0078d | |||
| 87a1d62414 | |||
| f863723a4e | |||
| afa1867abc | |||
| fcb656b7d0 | |||
| 9c0e2bef39 | |||
| 36692d1bc7 | |||
| 753714b639 | |||
| 27a26b8fe9 | |||
| 79260a0e47 | |||
| f9bcbfa74a | |||
| 0f2fb7121d | |||
| a635109308 | |||
| a61e09b075 | |||
| 763e44df12 | |||
| 69472a783e | |||
| 9a1ba06a21 | |||
| 14b2ee473a | |||
| ca2427c29a | |||
| ebacdcf82a | |||
| 7416108489 | |||
| bd6e57b3c6 | |||
| eed036ca03 | |||
| 55211b9b19 | |||
| 4af7c0f694 | |||
| f082fa8ac5 |
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'
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -151,11 +151,13 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# quick test
|
||||
quick.py
|
||||
# test files
|
||||
test-*.py
|
||||
|
||||
#config
|
||||
config.toml
|
||||
vban.toml
|
||||
|
||||
.vscode/
|
||||
|
||||
PING_FEATURE.md
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -11,6 +11,62 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
|
||||
|
||||
- [x]
|
||||
|
||||
## [2.9.0] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Recorder class, see [Recorder](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#recorder) in README.
|
||||
- Ping/pong implemented. If a pong is not received {VbanCmd}.login() will fail fast. This prevents the rt listener threads from starting up.
|
||||
- It has the added benefit of automatically detecting the type of VBAN server (Voicemeeter or Matrix).
|
||||
- A thread lock around the framecounter to improve thread safety since it can be accessed by both the main thread and the Producer thread.
|
||||
|
||||
|
||||
## [2.7.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- new kind `matrix` has been added, it does two things:
|
||||
- scales the interface according to `potato` kind, in practice this has no affect but it's required by the builder classes.
|
||||
- disables the rt listener threads since we aren't expecting to receive any from a Matrix VBAN server.
|
||||
- however, matrix responses may still be received with the {VbanCmd}.sendtext() method.
|
||||
|
||||
### Changed
|
||||
|
||||
- `outbound` kwarg has been renamed to `disable_rt_listeners`. Since it's job is to disable the listener threads for incoming RT packets this new name is more descriptive.
|
||||
- dataclasses representing packet headers and packets with ident:0 and ident:1 have been moved into an internal packet module.
|
||||
|
||||
### Removed
|
||||
|
||||
- {VbanCmd}.sendtext() @script decorator removed. It's purpose was to attempt to convert a dictionary to a script but it was poorly implemented and there exists the {VbanCmd}.apply() method already.
|
||||
|
||||
## [2.6.0] - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- support for packet with [ident:1](https://github.com/onyx-and-iris/Voicemeeter-SDK/blob/3be2c1c36563afbd6df3da8436406c77d2cc1f10/VoicemeeterRemote.h#L982) in VBAN TEXT subprotocol.
|
||||
- This includes Strip 3D, PEQ, comp, gate, denoiser and pitch parameters.
|
||||
|
||||
## [2.5.2] - 2025-01-25
|
||||
|
||||
### Changed
|
||||
|
||||
- ip kwargs defaults to 'localhost'
|
||||
- bps kwarg defaults to 256000.
|
||||
- factory builder steps now logged at `DEBUG` level.
|
||||
- Internal socket changes, they don't affect interface usage.
|
||||
|
||||
## [2.4.9] - 2023-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Error tests added in tests/test_errors.py
|
||||
- Errors section in README updated.
|
||||
|
||||
### Changed
|
||||
|
||||
- VBANCMDConnectionError class now subclasses VBANCMDError
|
||||
- If the configs loader is passed an invalid config TOML it will log an error but continue to load further configs into memory.
|
||||
|
||||
## [2.3.2] - 2023-07-12
|
||||
|
||||
### Added
|
||||
|
||||
186
README.md
186
README.md
@@ -1,26 +1,26 @@
|
||||
[](https://badge.fury.io/py/vban-cmd)
|
||||
[](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://pycqa.github.io/isort/)
|
||||
[](https://python-poetry.org/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||

|
||||

|
||||

|
||||
|
||||
# VBAN CMD
|
||||
|
||||
This python interface allows you to transmit Voicemeeter parameters over a network.
|
||||
This python interface allows you to send Voicemeeter/Matrix commands over a network.
|
||||
|
||||
It may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python)
|
||||
It offers the same public API as [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python).
|
||||
|
||||
There is no support for audio transfer in this package, only parameters.
|
||||
Only the VBAN SERVICE/TEXT subprotocols are supported, there is no support for AUDIO or MIDI in this package.
|
||||
|
||||
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
## Tested against
|
||||
|
||||
- Basic 1.0.8.8
|
||||
- Banana 2.0.6.8
|
||||
- Potato 3.0.2.8
|
||||
- Basic 1.1.2.2
|
||||
- Banana 2.1.2.2
|
||||
- Potato 3.1.2.2
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -29,7 +29,9 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
## Installation
|
||||
|
||||
`pip install vban-cmd`
|
||||
```console
|
||||
pip install vban-cmd
|
||||
```
|
||||
|
||||
## `Use`
|
||||
|
||||
@@ -39,14 +41,14 @@ Load VBAN connection info from toml config. A valid `vban.toml` might look like
|
||||
|
||||
```toml
|
||||
[connection]
|
||||
ip = "gamepc.local"
|
||||
host = "localhost"
|
||||
port = 6980
|
||||
streamname = "Command1"
|
||||
```
|
||||
|
||||
It should be placed in \<user home directory\> / "Documents" / "Voicemeeter" / "configs"
|
||||
|
||||
Alternatively you may pass `ip`, `port`, `streamname` as keyword arguments.
|
||||
Alternatively you may pass `host`, `port`, `streamname` as keyword arguments.
|
||||
|
||||
#### `__main__.py`
|
||||
|
||||
@@ -63,27 +65,27 @@ class ManyThings:
|
||||
self.vban = vban
|
||||
|
||||
def things(self):
|
||||
self.vban.strip[0].label = "podmic"
|
||||
self.vban.strip[0].label = 'podmic'
|
||||
self.vban.strip[0].mute = True
|
||||
print(
|
||||
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}"
|
||||
f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
|
||||
)
|
||||
|
||||
def other_things(self):
|
||||
self.vban.bus[3].gain = -6.3
|
||||
self.vban.bus[4].eq.on = True
|
||||
self.vban.bus[4].eq = True
|
||||
info = (
|
||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
||||
f"bus 4 eq has been set to {self.vban.bus[4].eq.on}",
|
||||
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||
)
|
||||
print("\n".join(info))
|
||||
print('\n'.join(info))
|
||||
|
||||
|
||||
def main():
|
||||
KIND_ID = "banana"
|
||||
KIND_ID = 'banana'
|
||||
|
||||
with vban_cmd.api(
|
||||
KIND_ID, ip="gamepc.local", port=6980, streamname="Command1"
|
||||
KIND_ID, host='localhost', port=6980, streamname='Command1'
|
||||
) as vban:
|
||||
do = ManyThings(vban)
|
||||
do.things()
|
||||
@@ -92,13 +94,14 @@ def main():
|
||||
# set many parameters at once
|
||||
vban.apply(
|
||||
{
|
||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-2": {"mute": True, "eq": {"on": True}},
|
||||
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-2': {'mute': True},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
@@ -112,6 +115,8 @@ Pass the kind of Voicemeeter as an argument. KIND_ID may be:
|
||||
- `banana`
|
||||
- `potato`
|
||||
|
||||
A fourth kind `matrix` has been added, if you pass it as a KIND_ID you are expected to use the [{VbanCmd}.sendtext()](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#vbansendtextscript) method for sending text requests.
|
||||
|
||||
## `Available commands`
|
||||
|
||||
### Strip
|
||||
@@ -146,8 +151,8 @@ Set mute state as value for the app matching name.
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.strip[5].appmute("Spotify", True)
|
||||
vban.strip[5].appgain("Spotify", 0.5)
|
||||
vban.strip[5].appmute('Spotify', True)
|
||||
vban.strip[5].appgain('Spotify', 0.5)
|
||||
```
|
||||
|
||||
##### Strip.Comp
|
||||
@@ -170,9 +175,7 @@ example:
|
||||
print(vban.strip[4].comp.knob)
|
||||
```
|
||||
|
||||
Strip Comp properties are defined as write only.
|
||||
|
||||
`knob` defined for all versions, all other parameters potato only.
|
||||
Strip Comp `knob` is defined for all versions, all other parameters potato only.
|
||||
|
||||
##### Strip.Gate
|
||||
|
||||
@@ -192,9 +195,7 @@ example:
|
||||
vban.strip[2].gate.attack = 300.8
|
||||
```
|
||||
|
||||
Strip Gate properties are defined as write only, potato version only.
|
||||
|
||||
`knob` defined for all versions, all other parameters potato only.
|
||||
Strip Gate `knob` is defined for all versions, all other parameters potato only.
|
||||
|
||||
##### Strip.Denoiser
|
||||
|
||||
@@ -211,7 +212,32 @@ The following properties are available.
|
||||
- `on`: 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
|
||||
|
||||
@@ -323,6 +349,40 @@ vban.strip[0].fadeto(-10.3, 1000)
|
||||
vban.bus[3].fadeby(-5.6, 500)
|
||||
```
|
||||
|
||||
### Recorder
|
||||
|
||||
The following methods are available
|
||||
|
||||
- `play()`
|
||||
- `stop()`
|
||||
- `pause()`
|
||||
- `record()`
|
||||
- `ff()`
|
||||
- `rew()`
|
||||
- `load(filepath)`: raw string
|
||||
- `goto(time_string)`: time string in format `hh:mm:ss`
|
||||
|
||||
The following properties are available
|
||||
|
||||
- `samplerate`: int, (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
|
||||
- `bitresolution`: int, (8, 16, 24, 32)
|
||||
- `channel`: int, from 1 to 8
|
||||
- `kbps`: int, (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
|
||||
- `gain`: float, from -60.0 to 12.0
|
||||
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.recorder.play()
|
||||
vban.recorder.stop()
|
||||
|
||||
# filepath as raw string
|
||||
vban.recorder.load(r'C:\music\mytune.mp3')
|
||||
|
||||
# set the goto time to 1m 30s
|
||||
vban.recorder.goto('00:01:30')
|
||||
```
|
||||
|
||||
### Command
|
||||
|
||||
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
|
||||
@@ -352,9 +412,10 @@ vban.command.showvbanchat = true
|
||||
```python
|
||||
vban.apply(
|
||||
{
|
||||
"strip-0": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-1": {"mute": True, "mode": "composite"},
|
||||
"bus-2": {"eq": {"on": True}},
|
||||
'strip-0': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-1': {'mute': True, 'mode': 'composite'},
|
||||
'bus-2': {'eq': {'on': True}},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -362,8 +423,8 @@ vban.apply(
|
||||
Or for each class you may do:
|
||||
|
||||
```python
|
||||
vban.strip[0].apply({"mute": True, "gain": 3.2, "A1": True})
|
||||
vban.vban.outstream[0].apply({"on": True, "name": "streamname", "bit": 24})
|
||||
vban.strip[0].apply({'mute': True, 'gain': 3.2, 'A1': True})
|
||||
vban.vban.outstream[0].apply({'on': True, 'name': 'streamname', 'bit': 24})
|
||||
```
|
||||
|
||||
## Config Files
|
||||
@@ -398,8 +459,8 @@ You just need to define a key `extends` in the config TOML, that names the confi
|
||||
Three example 'extender' configs are included with the repo. You may load them with:
|
||||
|
||||
```python
|
||||
import voicemeeterlib
|
||||
with voicemeeterlib.api('banana') as vm:
|
||||
import vban_cmd
|
||||
with vban_cmd.api('banana') as vm:
|
||||
vm.apply_config('extender')
|
||||
```
|
||||
|
||||
@@ -411,10 +472,11 @@ example:
|
||||
|
||||
```python
|
||||
import vban_cmd
|
||||
|
||||
opts = {
|
||||
"ip": "<ip address>",
|
||||
"streamname": "Command1",
|
||||
"port": 6980,
|
||||
'host': '<ip address>',
|
||||
'streamname': 'Command1',
|
||||
'port': 6980,
|
||||
}
|
||||
with vban_cmd.api('banana', ldirty=True, **opts) as vban:
|
||||
...
|
||||
@@ -467,7 +529,7 @@ The following methods are available:
|
||||
example:
|
||||
|
||||
```python
|
||||
vban.event.remove(["pdirty", "ldirty"])
|
||||
vban.event.remove(['pdirty', 'ldirty'])
|
||||
|
||||
# get a list of currently subscribed
|
||||
print(vban.event.get())
|
||||
@@ -479,13 +541,17 @@ print(vban.event.get())
|
||||
|
||||
You may pass the following optional keyword arguments:
|
||||
|
||||
- `ip`: str, ip or hostname of remote machine
|
||||
- `streamname`: str, name of the stream to connect to.
|
||||
- `host`: str='localhost', ip or hostname of remote machine
|
||||
- `port`: int=6980, vban udp port of remote machine.
|
||||
- `streamname`: str='Command1', name of the stream to connect to.
|
||||
- `bps`: int=256000, bps rate of the stream.
|
||||
- `channel`: int=0, channel on which to send the UDP requests.
|
||||
- `pdirty`: boolean=False, parameter updates
|
||||
- `ldirty`: boolean=False, level updates
|
||||
- `timeout`: int=5, amount of time (seconds) to wait for an incoming RT data packet (parameter states).
|
||||
- `outbound`: boolean=False, set `True` if you are only interested in sending commands. (no rt packets will be received)
|
||||
- `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`
|
||||
|
||||
@@ -500,18 +566,20 @@ True iff a level value has been changed.
|
||||
Sends a script block as a string request, for example:
|
||||
|
||||
```python
|
||||
vban.sendtext("Strip[0].Mute=1;Bus[0].Mono=1")
|
||||
vban.sendtext('Strip[0].Mute=1;Bus[0].Mono=1')
|
||||
```
|
||||
|
||||
#### `vban.public_packet`
|
||||
You can even use it to send matrix commands:
|
||||
|
||||
Returns a `VbanRtPacket`. Designed to be used internally by the interface but available for parsing through this read only property object.
|
||||
```python
|
||||
vban.sendtext('Point(ASIO128.IN[1..4],ASIO128.OUT[1]).dBGain = -3.0')
|
||||
|
||||
States not guaranteed to be current (requires use of dirty parameters to confirm).
|
||||
vban.sendtext('Command.Version = ?')
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
- `errors.VBANCMDError`: Exception raised when general errors occur.
|
||||
- `errors.VBANCMDError`: Base VBANCMD Exception class.
|
||||
- `errors.VBANCMDConnectionError`: Exception raised when connection/timeout errors occur.
|
||||
|
||||
## Logging
|
||||
@@ -524,18 +592,20 @@ import vban_cmd
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
opts = {"ip": "ip.local", "port": 6980, "streamname": "Command1"}
|
||||
opts = {'host': 'localhost', 'port': 6980, 'streamname': 'Command1'}
|
||||
with vban_cmd.api('banana', **opts) as vban:
|
||||
...
|
||||
...
|
||||
```
|
||||
|
||||
## Tests
|
||||
### Run tests
|
||||
|
||||
First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/vban-cmd-python#installation)
|
||||
Install [poetry](https://python-poetry.org/docs/#installation) and then:
|
||||
|
||||
Then from tests directory:
|
||||
|
||||
`pytest -v`
|
||||
```powershell
|
||||
poetry poe test-basic
|
||||
poetry poe test-banana
|
||||
poetry poe test-potato
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
21
__main__.py
21
__main__.py
@@ -6,27 +6,27 @@ class ManyThings:
|
||||
self.vban = vban
|
||||
|
||||
def things(self):
|
||||
self.vban.strip[0].label = "podmic"
|
||||
self.vban.strip[0].label = 'podmic'
|
||||
self.vban.strip[0].mute = True
|
||||
print(
|
||||
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}"
|
||||
f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
|
||||
)
|
||||
|
||||
def other_things(self):
|
||||
self.vban.bus[3].gain = -6.3
|
||||
self.vban.bus[4].eq = True
|
||||
info = (
|
||||
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
|
||||
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
|
||||
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
|
||||
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
|
||||
)
|
||||
print("\n".join(info))
|
||||
print('\n'.join(info))
|
||||
|
||||
|
||||
def main():
|
||||
kind_id = "banana"
|
||||
KIND_ID = 'banana'
|
||||
|
||||
with vban_cmd.api(
|
||||
kind_id, ip="gamepc.local", port=6980, streamname="Command1"
|
||||
KIND_ID, ip='gamepc.local', port=6980, streamname='Command1'
|
||||
) as vban:
|
||||
do = ManyThings(vban)
|
||||
do.things()
|
||||
@@ -35,11 +35,12 @@ def main():
|
||||
# set many parameters at once
|
||||
vban.apply(
|
||||
{
|
||||
"strip-2": {"A1": True, "B1": True, "gain": -6.0},
|
||||
"bus-2": {"mute": True},
|
||||
'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
|
||||
'bus-2': {'mute': True},
|
||||
'vban-in-0': {'on': True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
import vban_cmd
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
|
||||
class App(tk.Tk):
|
||||
@@ -13,7 +14,7 @@ class App(tk.Tk):
|
||||
def __init__(self, vban):
|
||||
super().__init__()
|
||||
self.vban = vban
|
||||
self.title(f"{vban} - version {vban.version}")
|
||||
self.title(f'{vban} - version {vban.version}')
|
||||
self.vban.observer.add(self.on_ldirty)
|
||||
|
||||
# create widget variables
|
||||
@@ -24,10 +25,10 @@ class App(tk.Tk):
|
||||
|
||||
# initialize style table
|
||||
self.style = ttk.Style()
|
||||
self.style.theme_use("clam")
|
||||
self.style.theme_use('clam')
|
||||
self.style.configure(
|
||||
"Mute.TButton",
|
||||
foreground="#cd5c5c" if vban.strip[self.INDEX].mute else "#5a5a5a",
|
||||
'Mute.TButton',
|
||||
foreground='#cd5c5c' if vban.strip[self.INDEX].mute else '#5a5a5a',
|
||||
)
|
||||
|
||||
# create labelframe and grid it onto the mainframe
|
||||
@@ -39,7 +40,7 @@ class App(tk.Tk):
|
||||
self.labelframe,
|
||||
from_=12,
|
||||
to_=-60,
|
||||
orient="vertical",
|
||||
orient='vertical',
|
||||
variable=self.slider_var,
|
||||
command=lambda arg: self.on_slider_move(arg),
|
||||
)
|
||||
@@ -47,15 +48,15 @@ class App(tk.Tk):
|
||||
column=0,
|
||||
row=0,
|
||||
)
|
||||
slider.bind("<Double-Button-1>", self.on_button_double_click)
|
||||
slider.bind('<Double-Button-1>', self.on_button_double_click)
|
||||
|
||||
# create level meter and grid it onto the labelframe
|
||||
level_meter = ttk.Progressbar(
|
||||
self.labelframe,
|
||||
orient="vertical",
|
||||
orient='vertical',
|
||||
variable=self.meter_var,
|
||||
maximum=72,
|
||||
mode="determinate",
|
||||
mode='determinate',
|
||||
)
|
||||
level_meter.grid(column=1, row=0)
|
||||
|
||||
@@ -66,8 +67,8 @@ class App(tk.Tk):
|
||||
# create button and grid it onto the labelframe
|
||||
button = ttk.Button(
|
||||
self.labelframe,
|
||||
text="Mute",
|
||||
style="Mute.TButton",
|
||||
text='Mute',
|
||||
style='Mute.TButton',
|
||||
command=lambda: self.on_button_press(),
|
||||
)
|
||||
button.grid(column=0, row=2, columnspan=2, padx=1, pady=2)
|
||||
@@ -83,7 +84,7 @@ class App(tk.Tk):
|
||||
self.button_var.set(not self.button_var.get())
|
||||
self.vban.strip[self.INDEX].mute = self.button_var.get()
|
||||
self.style.configure(
|
||||
"Mute.TButton", foreground="#cd5c5c" if self.button_var.get() else "#5a5a5a"
|
||||
'Mute.TButton', foreground='#cd5c5c' if self.button_var.get() else '#5a5a5a'
|
||||
)
|
||||
|
||||
def on_button_double_click(self, e):
|
||||
@@ -100,10 +101,17 @@ class App(tk.Tk):
|
||||
|
||||
|
||||
def main():
|
||||
with vban_cmd.api("banana", ldirty=True) as vban:
|
||||
KIND_ID = 'banana'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, ldirty=True, **conn) as vban:
|
||||
app = App(vban)
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
from logging import config
|
||||
|
||||
import obsws_python as obsws
|
||||
@@ -7,85 +8,103 @@ import vban_cmd
|
||||
|
||||
config.dictConfig(
|
||||
{
|
||||
"version": 1,
|
||||
"formatters": {
|
||||
"standard": {
|
||||
"format": "%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s"
|
||||
'version': 1,
|
||||
'formatters': {
|
||||
'standard': {
|
||||
'format': '%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s'
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"stream": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
'handlers': {
|
||||
'stream': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'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:
|
||||
def __init__(self, vban):
|
||||
self.vban = vban
|
||||
self.client = obsws.EventClient()
|
||||
self.client.callback.register(
|
||||
def __init__(self, vban, stop_event):
|
||||
self._vban = vban
|
||||
self._stop_event = stop_event
|
||||
self._client = obsws.EventClient()
|
||||
self._client.callback.register(
|
||||
(
|
||||
self.on_current_program_scene_changed,
|
||||
self.on_exit_started,
|
||||
)
|
||||
)
|
||||
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):
|
||||
self.vban.strip[0].mute = True
|
||||
self.vban.strip[1].B1 = True
|
||||
self.vban.strip[2].B2 = True
|
||||
self._vban.strip[0].mute = True
|
||||
self._vban.strip[1].B1 = True
|
||||
self._vban.strip[2].B2 = True
|
||||
|
||||
def on_brb(self):
|
||||
self.vban.strip[7].fadeto(0, 500)
|
||||
self.vban.bus[0].mute = True
|
||||
self._vban.strip[7].fadeto(0, 500)
|
||||
self._vban.bus[0].mute = True
|
||||
|
||||
def on_end(self):
|
||||
self.vban.apply(
|
||||
self._vban.apply(
|
||||
{
|
||||
"strip-0": {"mute": True},
|
||||
"strip-1": {"mute": True, "B1": False},
|
||||
"strip-2": {"mute": True, "B1": False},
|
||||
'strip-0': {'mute': True},
|
||||
'strip-1': {'mute': True, 'B1': False},
|
||||
'strip-2': {'mute': True, 'B1': False},
|
||||
}
|
||||
)
|
||||
|
||||
def on_live(self):
|
||||
self.vban.strip[0].mute = False
|
||||
self.vban.strip[7].fadeto(-6, 500)
|
||||
self.vban.strip[7].A3 = True
|
||||
self._vban.strip[0].mute = False
|
||||
self._vban.strip[7].fadeto(-6, 500)
|
||||
self._vban.strip[7].A3 = True
|
||||
|
||||
def on_current_program_scene_changed(self, data):
|
||||
def fget(scene):
|
||||
run = {
|
||||
"START": self.on_start,
|
||||
"BRB": self.on_brb,
|
||||
"END": self.on_end,
|
||||
"LIVE": self.on_live,
|
||||
}
|
||||
return run.get(scene)
|
||||
|
||||
scene = data.scene_name
|
||||
print(f"Switched to scene {scene}")
|
||||
if fn := fget(scene):
|
||||
fn()
|
||||
print(f'Switched to scene {scene}')
|
||||
match scene:
|
||||
case 'START':
|
||||
self.on_start()
|
||||
case 'BRB':
|
||||
self.on_brb()
|
||||
case 'END':
|
||||
self.on_end()
|
||||
case 'LIVE':
|
||||
self.on_live()
|
||||
|
||||
def on_exit_started(self, _):
|
||||
self.client.unsubscribe()
|
||||
self.is_running = False
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def main():
|
||||
with vban_cmd.api("potato") as vban:
|
||||
observer = Observer(vban)
|
||||
while observer.is_running:
|
||||
time.sleep(0.1)
|
||||
KIND_ID = 'potato'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, **conn) as vban:
|
||||
stop_event = threading.Event()
|
||||
|
||||
with Observer(vban, stop_event):
|
||||
stop_event.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="obs",
|
||||
description="OBS Example",
|
||||
install_requires=["obsws-python"],
|
||||
name='obs',
|
||||
description='OBS Example',
|
||||
install_requires=['obsws-python'],
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import vban_cmd
|
||||
|
||||
@@ -13,23 +14,28 @@ class App:
|
||||
|
||||
# define an 'on_update' callback function to receive event updates
|
||||
def on_update(self, event):
|
||||
if event == "pdirty":
|
||||
print("pdirty!")
|
||||
elif event == "ldirty":
|
||||
if event == 'pdirty':
|
||||
print('pdirty!')
|
||||
elif event == 'ldirty':
|
||||
for bus in self.vban.bus:
|
||||
if bus.levels.isdirty:
|
||||
print(bus, bus.levels.all)
|
||||
|
||||
|
||||
def main():
|
||||
KIND_ID = "banana"
|
||||
KIND_ID = 'banana'
|
||||
conn = {
|
||||
'host': os.environ.get('VBANCMD_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
|
||||
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
|
||||
}
|
||||
|
||||
with vban_cmd.api(KIND_ID, pdirty=True, ldirty=True) as vban:
|
||||
with vban_cmd.api(KIND_ID, pdirty=True, ldirty=True, **conn) as vban:
|
||||
App(vban)
|
||||
|
||||
while cmd := input("Press <Enter> to exit\n"):
|
||||
while _ := input('Press <Enter> to exit\n'):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
429
poetry.lock
generated
429
poetry.lock
generated
@@ -1,304 +1,363 @@
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "22.1.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "22.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)"]
|
||||
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "5.3.1"
|
||||
version = "5.5.0"
|
||||
description = "Extensible memoizing collections and decorators"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
|
||||
{file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "5.1.0"
|
||||
version = "5.2.0"
|
||||
description = "Universal encoding detector for Python 3"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.3"
|
||||
description = "Composable command line interface toolkit"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
|
||||
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.6"
|
||||
version = "0.3.9"
|
||||
description = "Distribution utilities"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
|
||||
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.2"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
|
||||
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.12.2"
|
||||
version = "3.16.1"
|
||||
description = "A platform independent file lock."
|
||||
category = "dev"
|
||||
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]
|
||||
docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)"]
|
||||
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
|
||||
typing = ["typing-extensions (>=4.12.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "1.1.1"
|
||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||
category = "dev"
|
||||
version = "2.0.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.10.1"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.1,<4.0"
|
||||
|
||||
[package.extras]
|
||||
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
|
||||
requirements_deprecated_finder = ["pipreqs", "pip-api"]
|
||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
||||
plugins = ["setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "23.1"
|
||||
version = "24.2"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.10.1"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "3.7.0"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
version = "4.3.6"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
|
||||
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.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 (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
|
||||
type = ["mypy (>=1.11.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.0.0"
|
||||
version = "1.5.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
category = "dev"
|
||||
name = "pyenv-inspect"
|
||||
version = "0.4.0"
|
||||
description = "An auxiliary library for the virtualenv-pyenv and tox-pyenv-redux plugins"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyenv-inspect-0.4.0.tar.gz", hash = "sha256:ec429d1d81b67ab0b08a0408414722a79d24fd1845a5b264267e44e19d8d60f0"},
|
||||
{file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyproject-api"
|
||||
version = "1.5.2"
|
||||
version = "1.8.0"
|
||||
description = "API to interact with the python pyproject.toml based projects"
|
||||
category = "dev"
|
||||
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]
|
||||
packaging = ">=23.1"
|
||||
packaging = ">=24.1"
|
||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.5.20)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx (>=7.0.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "wheel (>=0.40)"]
|
||||
docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
|
||||
testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.1.3"
|
||||
version = "8.3.4"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
|
||||
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
py = ">=1.8.2"
|
||||
tomli = ">=1.0.0"
|
||||
pluggy = ">=1.5,<2"
|
||||
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-randomly"
|
||||
version = "3.12.0"
|
||||
version = "3.16.0"
|
||||
description = "Pytest plugin to randomly order tests and control random.seed."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"},
|
||||
{file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-repeat"
|
||||
version = "0.9.1"
|
||||
description = "pytest plugin for repeating tests"
|
||||
category = "dev"
|
||||
name = "ruff"
|
||||
version = "0.9.2"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
|
||||
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
|
||||
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
|
||||
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
|
||||
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
|
||||
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
|
||||
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
|
||||
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
version = "2.2.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
|
||||
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
|
||||
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
|
||||
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
|
||||
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
|
||||
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tox"
|
||||
version = "4.6.3"
|
||||
version = "4.23.2"
|
||||
description = "tox is a generic virtualenv management and test command line tool"
|
||||
category = "dev"
|
||||
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]
|
||||
cachetools = ">=5.3.1"
|
||||
chardet = ">=5.1"
|
||||
cachetools = ">=5.5"
|
||||
chardet = ">=5.2"
|
||||
colorama = ">=0.4.6"
|
||||
filelock = ">=3.12.2"
|
||||
packaging = ">=23.1"
|
||||
platformdirs = ">=3.5.3"
|
||||
pluggy = ">=1"
|
||||
pyproject-api = ">=1.5.2"
|
||||
filelock = ">=3.16.1"
|
||||
packaging = ">=24.1"
|
||||
platformdirs = ">=4.3.6"
|
||||
pluggy = ">=1.5"
|
||||
pyproject-api = ">=1.8"
|
||||
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
|
||||
virtualenv = ">=20.23.1"
|
||||
typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
|
||||
virtualenv = ">=20.26.6"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.5.20)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "pytest (>=7.3.2)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"]
|
||||
test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.23.1"
|
||||
version = "20.29.0"
|
||||
description = "Virtual Python Environment builder"
|
||||
category = "dev"
|
||||
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]
|
||||
distlib = ">=0.3.6,<1"
|
||||
filelock = ">=3.12,<4"
|
||||
platformdirs = ">=3.5.1,<4"
|
||||
distlib = ">=0.3.7,<1"
|
||||
filelock = ">=3.12.2,<4"
|
||||
platformdirs = ">=3.9.1,<5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=7.0.1)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage-enable-subprocess (>=1)", "coverage (>=7.2.7)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.3.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv-pyenv"
|
||||
version = "0.5.0"
|
||||
description = "A virtualenv Python discovery plugin for pyenv-installed interpreters"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "virtualenv-pyenv-0.5.0.tar.gz", hash = "sha256:7b0e5fe3dfbdf484f4cf9b01e1f98111e398db6942237910f666356e6293597f"},
|
||||
{file = "virtualenv_pyenv-0.5.0-py3-none-any.whl", hash = "sha256:21750247e36c55b3c547cfdeb08f51a3867fe7129922991a4f9c96980c0a4a5d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyenv-inspect = ">=0.4,<0.5"
|
||||
virtualenv = "*"
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "5d0edd070ea010edb4e2ade88dc37324b8b4b04f22db78e49db161185365849b"
|
||||
|
||||
[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 = []
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10"
|
||||
content-hash = "13fc9d0eb15d5fc09b54c1c8cd8f528b260259e97ee6813b50ab4724c35d6677"
|
||||
|
||||
141
pyproject.toml
141
pyproject.toml
@@ -1,43 +1,138 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "vban-cmd"
|
||||
version = "2.4.3"
|
||||
version = "2.10.1"
|
||||
description = "Python interface for the VBAN RT Packet Service (Sendtext)"
|
||||
authors = ["onyx-and-iris <code@onyxandiris.online>"]
|
||||
license = "MIT"
|
||||
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/onyx-and-iris/vban-cmd-python"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["tomli (>=2.0.1,<3.0) ; python_version < '3.11'"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
tomli = { version = "^2.0.1", python = "<3.11" }
|
||||
[tool.poetry.requires-plugins]
|
||||
poethepoet = ">=0.42.0"
|
||||
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.2"
|
||||
pytest-randomly = "^3.12.0"
|
||||
pytest-repeat = "^0.9.1"
|
||||
black = "^22.3.0"
|
||||
isort = "^5.10.1"
|
||||
tox = "^4.6.3"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3.4"
|
||||
pytest-randomly = "^3.16.0"
|
||||
ruff = "^0.9.2"
|
||||
tox = "^4.23.2"
|
||||
virtualenv-pyenv = "^0.5.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
gui = "scripts:ex_gui"
|
||||
obs = "scripts:ex_obs"
|
||||
observer = "scripts:ex_observer"
|
||||
test = "scripts:test"
|
||||
[tool.poe]
|
||||
envfile = ".env"
|
||||
|
||||
[tool.poe.tasks]
|
||||
gui.script = "scripts:ex_gui"
|
||||
obs.script = "scripts:ex_obs"
|
||||
observer.script = "scripts:ex_observer"
|
||||
test-basic.script = "scripts:test_basic"
|
||||
test-banana.script = "scripts:test_banana"
|
||||
test-potato.script = "scripts:test_potato"
|
||||
test-all.script = "scripts:test_all"
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
envlist = py310,py311
|
||||
envlist = py310,py311,py312,py313
|
||||
|
||||
[testenv]
|
||||
passenv = *
|
||||
setenv = VIRTUALENV_DISCOVERY=pyenv
|
||||
allowlist_externals = poetry
|
||||
commands =
|
||||
poetry install -v
|
||||
poetry run pytest tests/
|
||||
|
||||
|
||||
[testenv:obs]
|
||||
setenv = VIRTUALENV_DISCOVERY=pyenv
|
||||
allowlist_externals = poetry
|
||||
deps = obsws-python
|
||||
commands =
|
||||
poetry install -v --without dev
|
||||
poetry run python examples/obs/
|
||||
"""
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"venv",
|
||||
]
|
||||
|
||||
# Same as Black.
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
# Assume Python 3.10
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
||||
# McCabe complexity (`C901`) by default.
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
ignore = []
|
||||
|
||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
# Allow unused variables when underscore-prefixed.
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
|
||||
[tool.ruff.format]
|
||||
# Unlike Black, use single quotes for strings.
|
||||
quote-style = "single"
|
||||
|
||||
# Like Black, indent with spaces, rather than tabs.
|
||||
indent-style = "space"
|
||||
|
||||
# Like Black, respect magic trailing commas.
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
# Enable auto-formatting of code examples in docstrings. Markdown,
|
||||
# reStructuredText code/literal blocks and doctests are all supported.
|
||||
#
|
||||
# This is currently disabled by default, but it is planned for this
|
||||
# to be opt-out in the future.
|
||||
docstring-code-format = false
|
||||
|
||||
# Set the line length limit used when formatting code snippets in
|
||||
# docstrings.
|
||||
#
|
||||
# This only has an effect when the `docstring-code-format` setting is
|
||||
# enabled.
|
||||
docstring-code-line-length = "dynamic"
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 10
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["E402", "F401"]
|
||||
|
||||
25
scripts.py
25
scripts.py
@@ -1,22 +1,35 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def ex_gui():
|
||||
scriptpath = Path.cwd() / "examples" / "gui" / "."
|
||||
scriptpath = Path.cwd() / 'examples' / 'gui' / '.'
|
||||
subprocess.run([sys.executable, str(scriptpath)])
|
||||
|
||||
|
||||
def ex_obs():
|
||||
scriptpath = Path.cwd() / "examples" / "obs" / "."
|
||||
subprocess.run([sys.executable, str(scriptpath)])
|
||||
subprocess.run(['tox', 'r', '-e', 'obs'])
|
||||
|
||||
|
||||
def ex_observer():
|
||||
scriptpath = Path.cwd() / "examples" / "observer" / "."
|
||||
scriptpath = Path.cwd() / 'examples' / 'observer' / '.'
|
||||
subprocess.run([sys.executable, str(scriptpath)])
|
||||
|
||||
|
||||
def test():
|
||||
subprocess.run(["tox"])
|
||||
def test_basic():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'basic'})
|
||||
|
||||
|
||||
def test_banana():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'banana'})
|
||||
|
||||
|
||||
def test_potato():
|
||||
subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'potato'})
|
||||
|
||||
|
||||
def test_all():
|
||||
steps = [test_basic, test_banana, test_potato]
|
||||
[step() for step in steps]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -6,14 +7,13 @@ import vban_cmd
|
||||
from vban_cmd.kinds import KindId
|
||||
from vban_cmd.kinds import request_kind_map as kindmap
|
||||
|
||||
# let's keep things random
|
||||
KIND_ID = random.choice(tuple(kind_id.name.lower() for kind_id in KindId))
|
||||
# get KIND from environment, if not set default to potato
|
||||
KIND_ID = os.environ.get('KIND', 'potato')
|
||||
|
||||
opts = {
|
||||
"ip": "testing.local",
|
||||
"streamname": "testing",
|
||||
"port": 6990,
|
||||
"bps": 0,
|
||||
'host': os.getenv('VBANCMD_HOST', 'localhost'),
|
||||
'streamname': os.getenv('VBANCMD_STREAMNAME', 'Command1'),
|
||||
'port': int(os.getenv('VBANCMD_PORT', 6980)),
|
||||
}
|
||||
|
||||
vban = vban_cmd.api(KIND_ID, **opts)
|
||||
@@ -39,7 +39,7 @@ data = Data()
|
||||
|
||||
|
||||
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.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):
|
||||
parser.addoption(
|
||||
"--run-slow",
|
||||
action="store_true",
|
||||
'--run-slow',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help="Run slow tests",
|
||||
help='Run slow tests',
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 52"><title>tests: 52</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">52</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">52</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60" height="20" role="img" aria-label="tests: 59"><title>tests: 59</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="60" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="37" height="20" fill="#555"/><rect x="37" width="23" height="20" fill="#4c1"/><rect width="60" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="195" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="270">tests</text><text x="195" y="140" transform="scale(.1)" fill="#fff" textLength="270">tests</text><text aria-hidden="true" x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="130">59</text><text x="475" y="140" transform="scale(.1)" fill="#fff" textLength="130">59</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -11,7 +11,7 @@ Function RunTests {
|
||||
$line | Tee-Object -FilePath $coverage -Append
|
||||
}
|
||||
}
|
||||
Write-Output "$(Get-TimeStamp)" | Out-file $coverage -Append
|
||||
Write-Output "$(Get-TimeStamp)" | Out-File $coverage -Append
|
||||
|
||||
Invoke-Expression "genbadge tests -t 90 -i ./tests/.coverage.xml -o ./tests/$kind.svg"
|
||||
}
|
||||
@@ -25,7 +25,10 @@ Function Get-TimeStamp {
|
||||
if ($MyInvocation.InvocationName -ne ".") {
|
||||
Invoke-Expression ".\.venv\Scripts\Activate.ps1"
|
||||
|
||||
RunTests
|
||||
@("potato") | ForEach-Object {
|
||||
$env:KIND = $_
|
||||
RunTests
|
||||
}
|
||||
|
||||
Invoke-Expression "deactivate"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests import data, vban
|
||||
@@ -10,18 +12,27 @@ class TestSetAndGetBoolHigher:
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
vban.apply_config("example")
|
||||
|
||||
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
|
||||
vban.apply_config('example')
|
||||
time.sleep(0.1)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--run-slow')",
|
||||
reason="Only run when --run-slow is given",
|
||||
reason='Only run when --run-slow is given',
|
||||
)
|
||||
def test_it_tests_config_string(self):
|
||||
assert 'PhysStrip' in vban.strip[data.phys_in].label
|
||||
assert 'VirtStrip' in vban.strip[data.virt_in].label
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--run-slow')",
|
||||
reason='Only run when --run-slow is given',
|
||||
)
|
||||
def test_it_tests_config_bool(self):
|
||||
assert vban.strip[0].A1
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--run-slow')",
|
||||
reason='Only run when --run-slow is given',
|
||||
)
|
||||
def test_it_tests_config_busmode(self):
|
||||
assert 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,15 +7,15 @@ class TestRemoteFactories:
|
||||
__test__ = True
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "basic",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'basic',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_basic(self):
|
||||
assert hasattr(vban, "strip")
|
||||
assert hasattr(vban, "bus")
|
||||
assert hasattr(vban, "command")
|
||||
assert hasattr(vban, "button")
|
||||
assert hasattr(vban, "vban")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(vban.strip) == 3
|
||||
assert len(vban.bus) == 2
|
||||
@@ -23,15 +23,15 @@ class TestRemoteFactories:
|
||||
assert len(vban.vban.instream) == 6 and len(vban.vban.outstream) == 5
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "banana",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'banana',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_banana(self):
|
||||
assert hasattr(vban, "strip")
|
||||
assert hasattr(vban, "bus")
|
||||
assert hasattr(vban, "command")
|
||||
assert hasattr(vban, "button")
|
||||
assert hasattr(vban, "vban")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(vban.strip) == 5
|
||||
assert len(vban.bus) == 5
|
||||
@@ -39,15 +39,15 @@ class TestRemoteFactories:
|
||||
assert len(vban.vban.instream) == 10 and len(vban.vban.outstream) == 9
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "potato",
|
||||
reason="Skip test if kind is not basic",
|
||||
data.name != 'potato',
|
||||
reason='Skip test if kind is not basic',
|
||||
)
|
||||
def test_it_tests_remote_attrs_for_potato(self):
|
||||
assert hasattr(vban, "strip")
|
||||
assert hasattr(vban, "bus")
|
||||
assert hasattr(vban, "command")
|
||||
assert hasattr(vban, "button")
|
||||
assert hasattr(vban, "vban")
|
||||
assert hasattr(vban, 'strip')
|
||||
assert hasattr(vban, 'bus')
|
||||
assert hasattr(vban, 'command')
|
||||
assert hasattr(vban, 'button')
|
||||
assert hasattr(vban, 'vban')
|
||||
|
||||
assert len(vban.strip) == 8
|
||||
assert len(vban.bus) == 8
|
||||
|
||||
@@ -3,17 +3,17 @@ import pytest
|
||||
from tests import data, vban
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, True])
|
||||
@pytest.mark.parametrize('value', [False, True])
|
||||
class TestSetAndGetBoolHigher:
|
||||
__test__ = True
|
||||
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_in, "mute"),
|
||||
(data.virt_in, "solo"),
|
||||
(data.phys_in, 'mute'),
|
||||
(data.virt_in, 'solo'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||
@@ -21,13 +21,13 @@ class TestSetAndGetBoolHigher:
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name == "banana",
|
||||
reason="Only test if logged into Basic or Potato version",
|
||||
data.name == 'banana',
|
||||
reason='Only test if logged into Basic or Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_in, "mc"),
|
||||
(data.phys_in, 'mc'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value):
|
||||
@@ -37,10 +37,9 @@ class TestSetAndGetBoolHigher:
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_out, "mute"),
|
||||
(data.virt_out, "sel"),
|
||||
(data.phys_out, 'mute'),
|
||||
],
|
||||
)
|
||||
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 """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param",
|
||||
'index,param',
|
||||
[
|
||||
(data.phys_out, "normal"),
|
||||
(data.phys_out, "amix"),
|
||||
(data.phys_out, "rearonly"),
|
||||
(data.virt_out, "normal"),
|
||||
(data.virt_out, "upmix41"),
|
||||
(data.virt_out, "composite"),
|
||||
(data.phys_out, 'normal'),
|
||||
(data.phys_out, 'amix'),
|
||||
(data.phys_out, 'rearonly'),
|
||||
(data.virt_out, 'normal'),
|
||||
(data.virt_out, 'upmix41'),
|
||||
(data.virt_out, 'composite'),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
|
||||
def test_it_sets_and_gets_bus_mode_bool_params(self, index, param, value):
|
||||
# here it only makes sense to set/get bus modes as True
|
||||
if not value:
|
||||
value = True
|
||||
@@ -71,8 +70,8 @@ class TestSetAndGetBoolHigher:
|
||||
""" command tests """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param",
|
||||
[("lock")],
|
||||
'param',
|
||||
[('lock')],
|
||||
)
|
||||
def test_it_sets_command_bool_params(self, param, value):
|
||||
setattr(vban.command, param, value)
|
||||
@@ -86,10 +85,10 @@ class TestSetAndGetIntHigher:
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param,value",
|
||||
'index,param,value',
|
||||
[
|
||||
(data.virt_in, "k", 0),
|
||||
(data.virt_in, "k", 4),
|
||||
(data.virt_in, 'k', 0),
|
||||
(data.virt_in, 'k', 4),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
|
||||
@@ -103,12 +102,12 @@ class TestSetAndGetFloatHigher:
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,param,value",
|
||||
'index,param,value',
|
||||
[
|
||||
(data.phys_in, "gain", -3.6),
|
||||
(data.phys_in, "gain", 3.6),
|
||||
(data.virt_in, "gain", -5.8),
|
||||
(data.virt_in, "gain", 5.8),
|
||||
(data.phys_in, 'gain', -3.6),
|
||||
(data.phys_in, 'gain', 3.6),
|
||||
(data.virt_in, 'gain', -5.8),
|
||||
(data.virt_in, 'gain', 5.8),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_float_params(self, index, param, value):
|
||||
@@ -116,18 +115,20 @@ class TestSetAndGetFloatHigher:
|
||||
assert getattr(vban.strip[index], param) == value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,value",
|
||||
'index,value',
|
||||
[(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
|
||||
)
|
||||
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
|
||||
def test_it_gets_strip_prefader_levels_and_compares_length_of_array(
|
||||
self, index, value
|
||||
):
|
||||
assert len(vban.strip[index].levels.prefader) == value
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "potato",
|
||||
reason="Only test if logged into Potato version",
|
||||
data.name != 'potato',
|
||||
reason='Only test if logged into Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index, j, value",
|
||||
'index, j, value',
|
||||
[
|
||||
(data.phys_in, 0, -20.7),
|
||||
(data.virt_in, 3, -60),
|
||||
@@ -142,14 +143,14 @@ class TestSetAndGetFloatHigher:
|
||||
""" strip tests, physical """
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "potato",
|
||||
reason="Only test if logged into Potato version",
|
||||
data.name != 'potato',
|
||||
reason='Only test if logged into Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
'index, param, value',
|
||||
[
|
||||
(data.phys_in, "gainin", -8.6),
|
||||
(data.phys_in, "knee", 0.24),
|
||||
(data.phys_in, 'gainin', -8.6),
|
||||
(data.phys_in, 'knee', 0.24),
|
||||
],
|
||||
)
|
||||
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.
|
||||
|
||||
@pytest.mark.skipif(
|
||||
data.name != "potato",
|
||||
reason="Only test if logged into Potato version",
|
||||
data.name != 'potato',
|
||||
reason='Only test if logged into Potato version',
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
'index, param, value',
|
||||
[
|
||||
(data.phys_in, "bpsidechain", 120),
|
||||
(data.phys_in, "hold", 3000),
|
||||
(data.phys_in, 'bpsidechain', 120),
|
||||
(data.phys_in, 'hold', 3000),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
|
||||
@@ -175,12 +176,13 @@ class TestSetAndGetFloatHigher:
|
||||
|
||||
""" strip tests, virtual """
|
||||
|
||||
@pytest.mark.skip(reason='Requires RT Packet NBS 1')
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
'index, param, value',
|
||||
[
|
||||
(data.virt_in, "treble", -1.6),
|
||||
(data.virt_in, "mid", 5.8),
|
||||
(data.virt_in, "bass", -8.1),
|
||||
(data.virt_in, 'treble', -1.6),
|
||||
(data.virt_in, 'mid', 5.8),
|
||||
(data.virt_in, 'bass', -8.1),
|
||||
],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
|
||||
@@ -190,30 +192,30 @@ class TestSetAndGetFloatHigher:
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param, value",
|
||||
[(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)],
|
||||
'index, param, value',
|
||||
[(data.phys_out, 'gain', -3.6), (data.virt_out, 'gain', 5.8)],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_float_params(self, index, param, value):
|
||||
setattr(vban.bus[index], param, value)
|
||||
assert getattr(vban.bus[index], param) == value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,value",
|
||||
'index,value',
|
||||
[(data.phys_out, 8), (data.virt_out, 8)],
|
||||
)
|
||||
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
|
||||
def test_it_gets_bus_levels_and_compares_length_of_array(self, index, value):
|
||||
assert len(vban.bus[index].levels.all) == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["test0", "test1"])
|
||||
@pytest.mark.parametrize('value', ['test0', 'test1'])
|
||||
class TestSetAndGetStringHigher:
|
||||
__test__ = True
|
||||
|
||||
"""strip tests, physical and virtual"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param",
|
||||
[(data.phys_in, "label"), (data.virt_in, "label")],
|
||||
'index, param',
|
||||
[(data.phys_in, 'label'), (data.virt_in, 'label')],
|
||||
)
|
||||
def test_it_sets_and_gets_strip_string_params(self, index, param, value):
|
||||
setattr(vban.strip[index], param, value)
|
||||
@@ -222,8 +224,8 @@ class TestSetAndGetStringHigher:
|
||||
""" bus tests, physical and virtual """
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index, param",
|
||||
[(data.phys_out, "label"), (data.virt_out, "label")],
|
||||
'index, param',
|
||||
[(data.phys_out, 'label'), (data.virt_out, 'label')],
|
||||
)
|
||||
def test_it_sets_and_gets_bus_string_params(self, index, param, value):
|
||||
setattr(vban.bus[index], param, value)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests import data, vban
|
||||
@@ -11,31 +9,26 @@ class TestPublicPacketLower:
|
||||
|
||||
"""Tests for a valid rt data packet"""
|
||||
|
||||
def test_it_gets_an_rt_data_packet(self):
|
||||
assert vban.public_packet.voicemeetertype in (
|
||||
kind.name for kind in kinds.kinds_all
|
||||
def test_it_gets_an_rt0_data_packet(self):
|
||||
assert vban.public_packets[0].voicemeetertype in (
|
||||
kind.name for kind in kinds.all
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--run-slow')",
|
||||
reason="Only run when --run-slow is given",
|
||||
)
|
||||
@pytest.mark.parametrize("value", [0, 1])
|
||||
@pytest.mark.parametrize('value', [0, 1])
|
||||
class TestSetRT:
|
||||
__test__ = True
|
||||
|
||||
"""Tests set_rt"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kls,index,param",
|
||||
'kls,index,param',
|
||||
[
|
||||
("strip", data.phys_in, "mute"),
|
||||
("bus", data.virt_out, "mono"),
|
||||
('strip', data.phys_in, 'mute'),
|
||||
('bus', data.virt_out, 'mono'),
|
||||
],
|
||||
)
|
||||
def test_it_sends_a_text_request(self, kls, index, param, value):
|
||||
vban._set_rt(f"{kls}[{index}]", param, value)
|
||||
time.sleep(0.02)
|
||||
vban._set_rt(f'{kls}[{index}].{param}', value)
|
||||
target = getattr(vban, kls)[index]
|
||||
assert getattr(target, param) == bool(value)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .factory import request_vbancmd_obj as api
|
||||
|
||||
__ALL__ = ["api"]
|
||||
__ALL__ = ['api']
|
||||
|
||||
135
vban_cmd/bus.py
135
vban_cmd/bus.py
@@ -1,16 +1,10 @@
|
||||
import abc
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from enum import IntEnum
|
||||
from typing import Union
|
||||
|
||||
from .enums import NBS, BusModes
|
||||
from .iremote import IRemote
|
||||
from .meta import bus_mode_prop, channel_bool_prop, channel_label_prop
|
||||
|
||||
BusModes = IntEnum(
|
||||
"BusModes",
|
||||
"normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly",
|
||||
start=0,
|
||||
)
|
||||
from .meta import bus_mode_prop, channel_bool_prop, channel_int_prop, channel_label_prop
|
||||
|
||||
|
||||
class Bus(IRemote):
|
||||
@@ -20,35 +14,32 @@ class Bus(IRemote):
|
||||
Defines concrete implementation for bus
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}]"
|
||||
return f'bus[{self.index}]'
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
def fget():
|
||||
val = self.public_packet.busgain[self.index]
|
||||
if 0 <= val <= 1200:
|
||||
return val * 0.01
|
||||
return (((1 << 16) - 1) - val) * -0.01
|
||||
|
||||
val = self.getter("gain")
|
||||
return round(val if val else fget(), 1)
|
||||
val = self.getter('gain')
|
||||
if val:
|
||||
return round(val, 2)
|
||||
else:
|
||||
return self.public_packets[NBS.zero].busgain[self.index]
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter("gain", val)
|
||||
self.setter('gain', val)
|
||||
|
||||
def fadeto(self, target: float, time_: int):
|
||||
self.setter("FadeTo", f"({target}, {time_})")
|
||||
self.setter('FadeTo', f'({target}, {time_})')
|
||||
time.sleep(self._remote.DELAY)
|
||||
|
||||
def fadeby(self, change: float, time_: int):
|
||||
self.setter("FadeBy", f"({change}, {time_})")
|
||||
self.setter('FadeBy', f'({change}, {time_})')
|
||||
time.sleep(self._remote.DELAY)
|
||||
|
||||
|
||||
@@ -56,22 +47,22 @@ class BusEQ(IRemote):
|
||||
@classmethod
|
||||
def make(cls, remote, index):
|
||||
BUSEQ_cls = type(
|
||||
f"BusEQ{remote.kind}",
|
||||
f'BusEQ{remote.kind}',
|
||||
(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)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}].eq"
|
||||
return f'bus[{self.index}].eq'
|
||||
|
||||
|
||||
class PhysicalBus(Bus):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
@property
|
||||
def device(self) -> str:
|
||||
@@ -84,7 +75,7 @@ class PhysicalBus(Bus):
|
||||
|
||||
class VirtualBus(Bus):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
|
||||
class BusLevel(IRemote):
|
||||
@@ -99,24 +90,13 @@ class BusLevel(IRemote):
|
||||
def getter(self):
|
||||
"""Returns a tuple of level values for the channel."""
|
||||
|
||||
def fget(i):
|
||||
return round((((1 << 16) - 1) - i) * -0.01, 1)
|
||||
|
||||
if not self._remote.stopped() 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]
|
||||
]
|
||||
)
|
||||
return self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
|
||||
return self.public_packets[NBS.zero].levels.bus[self.range[0] : self.range[-1]]
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}]"
|
||||
return f'bus[{self.index}]'
|
||||
|
||||
@property
|
||||
def all(self) -> tuple:
|
||||
@@ -137,37 +117,51 @@ class BusLevel(IRemote):
|
||||
def _make_bus_mode_mixin():
|
||||
"""Creates a mixin of Bus Modes."""
|
||||
|
||||
mode_names = [
|
||||
'normal',
|
||||
'amix',
|
||||
'repeat',
|
||||
'bmix',
|
||||
'composite',
|
||||
'tvmix',
|
||||
'upmix21',
|
||||
'upmix41',
|
||||
'upmix61',
|
||||
'centeronly',
|
||||
'lfeonly',
|
||||
'rearonly',
|
||||
]
|
||||
|
||||
def identifier(self) -> str:
|
||||
return f"Bus[{self.index}].mode"
|
||||
return f'bus[{self.index}].mode'
|
||||
|
||||
def get(self):
|
||||
time.sleep(0.01)
|
||||
for i, val in enumerate(
|
||||
[
|
||||
self.amix,
|
||||
self.bmix,
|
||||
self.repeat,
|
||||
self.composite,
|
||||
self.tvmix,
|
||||
self.upmix21,
|
||||
self.upmix41,
|
||||
self.upmix61,
|
||||
self.centeronly,
|
||||
self.lfeonly,
|
||||
self.rearonly,
|
||||
]
|
||||
):
|
||||
if val:
|
||||
return BusModes(i + 1).name
|
||||
return "normal"
|
||||
"""Get current bus mode using ChannelState for clean bit extraction."""
|
||||
mode_cache_items = [
|
||||
(k, v)
|
||||
for k, v in self._remote.cache.items()
|
||||
if k.startswith(f'{self.identifier}.') and v == 1
|
||||
]
|
||||
|
||||
if mode_cache_items:
|
||||
latest_cached = mode_cache_items[-1][0]
|
||||
mode_name = latest_cached.split('.')[-1]
|
||||
return mode_name
|
||||
|
||||
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||
|
||||
# Extract bus mode from bits 4-7 (mask 0xF0, shift right by 4)
|
||||
mode_value = (bus_state._state & 0x000000F0) >> 4
|
||||
|
||||
return mode_names[mode_value] if mode_value < len(mode_names) else 'normal'
|
||||
|
||||
return type(
|
||||
"BusModeMixin",
|
||||
'BusModeMixin',
|
||||
(IRemote,),
|
||||
{
|
||||
"identifier": property(identifier),
|
||||
'identifier': property(identifier),
|
||||
**{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
|
||||
"get": get,
|
||||
'get': get,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -181,14 +175,15 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
|
||||
BUS_cls = PhysicalBus if phys_bus else VirtualBus
|
||||
BUSMODEMIXIN_cls = _make_bus_mode_mixin()
|
||||
return type(
|
||||
f"{BUS_cls.__name__}{remote.kind}",
|
||||
f'{BUS_cls.__name__}{remote.kind}',
|
||||
(BUS_cls,),
|
||||
{
|
||||
"eq": BusEQ.make(remote, i),
|
||||
"levels": BusLevel(remote, i),
|
||||
"mode": BUSMODEMIXIN_cls(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ["mute", "mono"]},
|
||||
"label": channel_label_prop(),
|
||||
'eq': BusEQ.make(remote, i),
|
||||
'levels': BusLevel(remote, i),
|
||||
'mode': BUSMODEMIXIN_cls(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ('mute',)},
|
||||
**{param: channel_int_prop(param) for param in ('mono',)},
|
||||
'label': channel_label_prop(),
|
||||
},
|
||||
)(remote, i)
|
||||
|
||||
|
||||
@@ -17,30 +17,30 @@ class Command(IRemote):
|
||||
Returns a Command class of a kind.
|
||||
"""
|
||||
CMD_cls = type(
|
||||
f"Command{remote.kind}",
|
||||
f'Command{remote.kind}',
|
||||
(cls,),
|
||||
{
|
||||
**{
|
||||
param: action_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)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return "Command"
|
||||
return 'command'
|
||||
|
||||
def set_showvbanchat(self, val: bool):
|
||||
self.setter("DialogShow.VBANCHAT", 1 if val else 0)
|
||||
self.setter('DialogShow.VBANCHAT', 1 if val else 0)
|
||||
|
||||
showvbanchat = property(fset=set_showvbanchat)
|
||||
|
||||
def set_lock(self, val: bool):
|
||||
self.setter("lock", 1 if val else 0)
|
||||
self.setter('lock', 1 if val else 0)
|
||||
|
||||
lock = property(fset=set_lock)
|
||||
|
||||
def reset(self):
|
||||
self._remote.apply_config("reset")
|
||||
self._remote.apply_config('reset')
|
||||
|
||||
@@ -20,73 +20,73 @@ class TOMLStrBuilder:
|
||||
def __init__(self, kind):
|
||||
self.kind = kind
|
||||
self.higher = itertools.chain(
|
||||
[f"strip-{i}" for i in range(kind.num_strip)],
|
||||
[f"bus-{i}" for i in range(kind.num_bus)],
|
||||
[f'strip-{i}' for i in range(kind.num_strip)],
|
||||
[f'bus-{i}' for i in range(kind.num_bus)],
|
||||
)
|
||||
|
||||
def init_config(self, profile=None):
|
||||
self.virt_strip_params = (
|
||||
[
|
||||
"mute = false",
|
||||
"mono = false",
|
||||
"solo = false",
|
||||
"gain = 0.0",
|
||||
'mute = false',
|
||||
'mono = false',
|
||||
'solo = false',
|
||||
'gain = 0.0',
|
||||
]
|
||||
+ [f"A{i} = false" for i in range(1, self.kind.phys_out + 1)]
|
||||
+ [f"B{i} = false" for i in range(1, self.kind.virt_out + 1)]
|
||||
+ [f'A{i} = false' for i in range(1, self.kind.phys_out + 1)]
|
||||
+ [f'B{i} = false' for i in range(1, self.kind.virt_out + 1)]
|
||||
)
|
||||
self.phys_strip_params = self.virt_strip_params + [
|
||||
"comp.knob = 0.0",
|
||||
"gate.knob = 0.0",
|
||||
"denoiser.knob = 0.0",
|
||||
"eq.on = false",
|
||||
'comp.knob = 0.0',
|
||||
'gate.knob = 0.0',
|
||||
'denoiser.knob = 0.0',
|
||||
'eq.on = false',
|
||||
]
|
||||
self.bus_float = ["gain = 0.0"]
|
||||
self.bus_float = ['gain = 0.0']
|
||||
self.bus_params = [
|
||||
"mono = false",
|
||||
"eq.on = false",
|
||||
"mute = false",
|
||||
"gain = 0.0",
|
||||
'mono = false',
|
||||
'eq.on = false',
|
||||
'mute = false',
|
||||
'gain = 0.0',
|
||||
]
|
||||
|
||||
if profile == "reset":
|
||||
if profile == 'reset':
|
||||
self.reset_config()
|
||||
|
||||
def reset_config(self):
|
||||
self.phys_strip_params = list(
|
||||
map(lambda x: x.replace("B1 = false", "B1 = true"), self.phys_strip_params)
|
||||
map(lambda x: x.replace('B1 = false', 'B1 = true'), self.phys_strip_params)
|
||||
)
|
||||
self.virt_strip_params = list(
|
||||
map(lambda x: x.replace("A1 = false", "A1 = true"), self.virt_strip_params)
|
||||
map(lambda x: x.replace('A1 = false', 'A1 = true'), self.virt_strip_params)
|
||||
)
|
||||
|
||||
def build(self, profile="reset"):
|
||||
def build(self, profile='reset'):
|
||||
self.init_config(profile)
|
||||
toml_str = str()
|
||||
for eachclass in self.higher:
|
||||
toml_str += f"[{eachclass}]\n"
|
||||
toml_str += f'[{eachclass}]\n'
|
||||
toml_str = self.join(eachclass, toml_str)
|
||||
return toml_str
|
||||
|
||||
def join(self, eachclass, toml_str):
|
||||
kls, index = eachclass.split("-")
|
||||
kls, index = eachclass.split('-')
|
||||
match kls:
|
||||
case "strip":
|
||||
toml_str += ("\n").join(
|
||||
case 'strip':
|
||||
toml_str += ('\n').join(
|
||||
self.phys_strip_params
|
||||
if int(index) < self.kind.phys_in
|
||||
else self.virt_strip_params
|
||||
)
|
||||
case "bus":
|
||||
toml_str += ("\n").join(self.bus_params)
|
||||
case 'bus':
|
||||
toml_str += ('\n').join(self.bus_params)
|
||||
case _:
|
||||
pass
|
||||
return toml_str + "\n"
|
||||
return toml_str + '\n'
|
||||
|
||||
|
||||
class TOMLDataExtractor:
|
||||
def __init__(self, file):
|
||||
with open(file, "rb") as f:
|
||||
with open(file, 'rb') as f:
|
||||
self._data = tomllib.load(f)
|
||||
|
||||
@property
|
||||
@@ -104,10 +104,10 @@ def dataextraction_factory(file):
|
||||
|
||||
this opens the possibility for other parsers to be added
|
||||
"""
|
||||
if file.suffix == ".toml":
|
||||
if file.suffix == '.toml':
|
||||
extractor = TOMLDataExtractor
|
||||
else:
|
||||
raise ValueError("Cannot extract data from {}".format(file))
|
||||
raise ValueError('Cannot extract data from {}'.format(file))
|
||||
return extractor(file)
|
||||
|
||||
|
||||
@@ -141,20 +141,25 @@ class Loader(metaclass=SingletonType):
|
||||
def defaults(self, kind):
|
||||
self.builder = TOMLStrBuilder(kind)
|
||||
toml_str = self.builder.build()
|
||||
self.register("reset", tomllib.loads(toml_str))
|
||||
self.register('reset', tomllib.loads(toml_str))
|
||||
|
||||
def parse(self, identifier, data):
|
||||
if identifier in self._configs:
|
||||
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
|
||||
self.parser = dataextraction_factory(data)
|
||||
return
|
||||
try:
|
||||
self.parser = dataextraction_factory(data)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
ERR_MSG = (str(e), f'When attempting to load {identifier}.toml')
|
||||
self.logger.error(f'{type(e).__name__}: {" ".join(ERR_MSG)}')
|
||||
return
|
||||
return True
|
||||
|
||||
def register(self, identifier, data=None):
|
||||
self._configs[identifier] = data if data else self.parser.data
|
||||
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):
|
||||
self._configs.clear()
|
||||
@@ -177,18 +182,18 @@ def loader(kind):
|
||||
|
||||
returns configs loaded into memory
|
||||
"""
|
||||
logger_loader = logger.getChild("loader")
|
||||
logger_loader = logger.getChild('loader')
|
||||
loader = Loader(kind)
|
||||
|
||||
for path in (
|
||||
Path.cwd() / "configs" / kind.name,
|
||||
Path.home() / ".config" / "vban-cmd" / kind.name,
|
||||
Path.home() / "Documents" / "Voicemeeter" / "configs" / kind.name,
|
||||
Path.cwd() / 'configs' / kind.name,
|
||||
Path.home() / '.config' / 'vban-cmd' / kind.name,
|
||||
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / kind.name,
|
||||
):
|
||||
if path.is_dir():
|
||||
logger_loader.info(f"Checking [{path}] for TOML config files:")
|
||||
for file in path.glob("*.toml"):
|
||||
identifier = file.with_suffix("").stem
|
||||
logger_loader.info(f'Checking [{path}] for TOML config files:')
|
||||
for file in path.glob('*.toml'):
|
||||
identifier = file.with_suffix('').stem
|
||||
if loader.parse(identifier, file):
|
||||
loader.register(identifier)
|
||||
return loader.configs
|
||||
@@ -203,5 +208,5 @@ def request_config(kind_id: str):
|
||||
try:
|
||||
configs = loader(kindmap(kind_id))
|
||||
except KeyError:
|
||||
raise VBANCMDError(f"Unknown Voicemeeter kind {kind_id}")
|
||||
raise VBANCMDError(f'Unknown Voicemeeter kind {kind_id}')
|
||||
return configs
|
||||
|
||||
20
vban_cmd/enums.py
Normal file
20
vban_cmd/enums.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from enum import Enum, IntEnum, unique
|
||||
|
||||
|
||||
@unique
|
||||
class KindId(Enum):
|
||||
BASIC = 1
|
||||
BANANA = 2
|
||||
POTATO = 3
|
||||
|
||||
|
||||
class NBS(IntEnum):
|
||||
zero = 0
|
||||
one = 1
|
||||
|
||||
|
||||
BusModes = IntEnum(
|
||||
'BusModes',
|
||||
'normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly',
|
||||
start=0,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
class VBANCMDError(Exception):
|
||||
"""Base VBANCMD Exception class. Raised when general errors occur"""
|
||||
"""Base VBANCMD Exception class."""
|
||||
|
||||
|
||||
class VBANCMDConnectionError(VBANCMDError):
|
||||
|
||||
@@ -12,30 +12,30 @@ class Event:
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
|
||||
def info(self, msg=None):
|
||||
info = (f"{msg} events",) if msg else ()
|
||||
info = (f'{msg} events',) if msg else ()
|
||||
if self.any():
|
||||
info += (f"now listening for {', '.join(self.get())} events",)
|
||||
info += (f'now listening for {", ".join(self.get())} events',)
|
||||
else:
|
||||
info += (f"not listening for any events",)
|
||||
self.logger.info(", ".join(info))
|
||||
info += ('not listening for any events',)
|
||||
self.logger.info(', '.join(info))
|
||||
|
||||
@property
|
||||
def pdirty(self) -> bool:
|
||||
return self.subs["pdirty"]
|
||||
return self.subs['pdirty']
|
||||
|
||||
@pdirty.setter
|
||||
def pdirty(self, val: bool):
|
||||
self.subs["pdirty"] = val
|
||||
self.info(f"pdirty {'added to' if val else 'removed from'}")
|
||||
self.subs['pdirty'] = val
|
||||
self.info(f'pdirty {"added to" if val else "removed from"}')
|
||||
|
||||
@property
|
||||
def ldirty(self) -> bool:
|
||||
return self.subs["ldirty"]
|
||||
return self.subs['ldirty']
|
||||
|
||||
@ldirty.setter
|
||||
def ldirty(self, val: bool):
|
||||
self.subs["ldirty"] = val
|
||||
self.info(f"ldirty {'added to' if val else 'removed from'}")
|
||||
self.subs['ldirty'] = val
|
||||
self.info(f'ldirty {"added to" if val else "removed from"}')
|
||||
|
||||
def get(self) -> list:
|
||||
return [k for k, v in self.subs.items() if v]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import abc
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from enum import IntEnum
|
||||
from functools import cached_property
|
||||
from typing import Iterable
|
||||
@@ -11,6 +11,7 @@ from .error import VBANCMDError
|
||||
from .kinds import KindMapClass
|
||||
from .kinds import request_kind_map as kindmap
|
||||
from .macrobutton import MacroButton
|
||||
from .recorder import Recorder
|
||||
from .strip import request_strip_obj as strip
|
||||
from .vban import request_vban_obj as vban
|
||||
from .vbancmd import VbanCmd
|
||||
@@ -26,25 +27,26 @@ class FactoryBuilder:
|
||||
"""
|
||||
|
||||
BuilderProgress = IntEnum(
|
||||
"BuilderProgress", "strip bus command macrobutton vban", start=0
|
||||
'BuilderProgress', 'strip bus command macrobutton vban recorder', start=0
|
||||
)
|
||||
|
||||
def __init__(self, factory, kind: KindMapClass):
|
||||
self._factory = factory
|
||||
self.kind = kind
|
||||
self._info = (
|
||||
f"Finished building strips for {self._factory}",
|
||||
f"Finished building buses for {self._factory}",
|
||||
f"Finished building commands for {self._factory}",
|
||||
f"Finished building macrobuttons for {self._factory}",
|
||||
f"Finished building vban in/out streams for {self._factory}",
|
||||
f'Finished building strips for {self._factory}',
|
||||
f'Finished building buses for {self._factory}',
|
||||
f'Finished building commands for {self._factory}',
|
||||
f'Finished building macrobuttons for {self._factory}',
|
||||
f'Finished building vban in/out streams for {self._factory}',
|
||||
f'Finished building recorder for {self._factory}',
|
||||
)
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
|
||||
def _pinfo(self, name: str) -> None:
|
||||
"""prints progress status for each step"""
|
||||
name = name.split("_")[1]
|
||||
self.logger.info(self._info[int(getattr(self.BuilderProgress, name))])
|
||||
name = name.split('_')[1]
|
||||
self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
|
||||
|
||||
def make_strip(self):
|
||||
self._factory.strip = tuple(
|
||||
@@ -72,26 +74,32 @@ class FactoryBuilder:
|
||||
self._factory.vban = vban(self._factory)
|
||||
return self
|
||||
|
||||
def make_recorder(self):
|
||||
self._factory.recorder = Recorder.make(self._factory)
|
||||
return self
|
||||
|
||||
|
||||
class FactoryBase(VbanCmd):
|
||||
"""Base class for factories, subclasses VbanCmd."""
|
||||
|
||||
def __init__(self, kind_id: str, **kwargs):
|
||||
defaultkwargs = {
|
||||
"ip": None,
|
||||
"port": 6980,
|
||||
"streamname": "Command1",
|
||||
"bps": 0,
|
||||
"channel": 0,
|
||||
"ratelimit": 0.01,
|
||||
"timeout": 5,
|
||||
"outbound": False,
|
||||
"sync": False,
|
||||
"pdirty": False,
|
||||
"ldirty": False,
|
||||
'host': 'localhost',
|
||||
'port': 6980,
|
||||
'streamname': 'Command1',
|
||||
'bps': 256000,
|
||||
'channel': 0,
|
||||
'script_ratelimit': 0.05, # 20 commands per second, to avoid overloading Voicemeeter
|
||||
'timeout': 5, # timeout on socket operations, in seconds
|
||||
'disable_rt_listeners': False,
|
||||
'sync': False,
|
||||
'pdirty': False,
|
||||
'ldirty': False,
|
||||
}
|
||||
if "subs" in kwargs:
|
||||
defaultkwargs |= kwargs.pop("subs") # for backwards compatibility
|
||||
if 'ip' in kwargs:
|
||||
defaultkwargs['host'] = kwargs.pop('ip') # for backwards compatibility
|
||||
if 'subs' in kwargs:
|
||||
defaultkwargs |= kwargs.pop('subs') # for backwards compatibility
|
||||
kwargs = defaultkwargs | kwargs
|
||||
self.kind = kindmap(kind_id)
|
||||
super().__init__(**kwargs)
|
||||
@@ -106,7 +114,7 @@ class FactoryBase(VbanCmd):
|
||||
self._configs = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Voicemeeter {self.kind}"
|
||||
return f'Voicemeeter {self.kind}'
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
@@ -115,7 +123,7 @@ class FactoryBase(VbanCmd):
|
||||
)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def steps(self):
|
||||
pass
|
||||
|
||||
@@ -166,7 +174,7 @@ class BananaFactory(FactoryBase):
|
||||
@property
|
||||
def steps(self) -> Iterable:
|
||||
"""steps required to build the interface for a kind"""
|
||||
return self._steps
|
||||
return self._steps + (self.builder.make_recorder,)
|
||||
|
||||
|
||||
class PotatoFactory(FactoryBase):
|
||||
@@ -188,7 +196,7 @@ class PotatoFactory(FactoryBase):
|
||||
@property
|
||||
def steps(self) -> Iterable:
|
||||
"""steps required to build the interface for a kind"""
|
||||
return self._steps
|
||||
return self._steps + (self.builder.make_recorder,)
|
||||
|
||||
|
||||
def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
||||
@@ -198,15 +206,21 @@ def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
|
||||
Returns a VbanCmd class of a kind
|
||||
"""
|
||||
match kind_id:
|
||||
case "basic":
|
||||
case 'basic':
|
||||
_factory = BasicFactory
|
||||
case "banana":
|
||||
case 'banana':
|
||||
_factory = BananaFactory
|
||||
case "potato":
|
||||
case 'potato' | 'matrix':
|
||||
# matrix is a special kind where:
|
||||
# - we don't need to scale the interface with the builder (in other words kind is arbitrary).
|
||||
# - we don't ever need to use real-time listeners, so we disable them to avoid confusion
|
||||
if kind_id == 'matrix':
|
||||
kwargs['disable_rt_listeners'] = True
|
||||
kind_id = 'potato'
|
||||
_factory = PotatoFactory
|
||||
case _:
|
||||
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
|
||||
return type(f"VbanCmd{kind_id.capitalize()}", (_factory,), {})(kind_id, **kwargs)
|
||||
return type(f'VbanCmd{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs)
|
||||
|
||||
|
||||
def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
||||
@@ -215,12 +229,12 @@ def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
|
||||
|
||||
Returns a reference to a VbanCmd class of a kind
|
||||
"""
|
||||
logger_entry = logger.getChild("factory.request_vbancmd_obj")
|
||||
logger_entry = logger.getChild('factory.request_vbancmd_obj')
|
||||
|
||||
VBANCMD_obj = None
|
||||
try:
|
||||
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
|
||||
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
|
||||
return VBANCMD_obj
|
||||
|
||||
@@ -1,84 +1,10 @@
|
||||
import abc
|
||||
import logging
|
||||
import time
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Modes:
|
||||
"""Channel Modes"""
|
||||
|
||||
_mute: hex = 0x00000001
|
||||
_solo: hex = 0x00000002
|
||||
_mono: hex = 0x00000004
|
||||
_mc: hex = 0x00000008
|
||||
|
||||
_amix: hex = 0x00000010
|
||||
_repeat: hex = 0x00000020
|
||||
_bmix: hex = 0x00000030
|
||||
_composite: hex = 0x00000040
|
||||
_tvmix: hex = 0x00000050
|
||||
_upmix21: hex = 0x00000060
|
||||
_upmix41: hex = 0x00000070
|
||||
_upmix61: hex = 0x00000080
|
||||
_centeronly: hex = 0x00000090
|
||||
_lfeonly: hex = 0x000000A0
|
||||
_rearonly: hex = 0x000000B0
|
||||
|
||||
_mask: hex = 0x000000F0
|
||||
|
||||
_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):
|
||||
class IRemote(abc.ABC):
|
||||
"""
|
||||
Common interface between base class and extended (higher) classes
|
||||
|
||||
@@ -89,11 +15,10 @@ class IRemote(metaclass=ABCMeta):
|
||||
self._remote = remote
|
||||
self.index = index
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self._modes = Modes()
|
||||
|
||||
def getter(self, param):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f"getter: {cmd}")
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
if cmd in self._remote.cache:
|
||||
return self._remote.cache.pop(cmd)
|
||||
if self._remote.sync:
|
||||
@@ -101,33 +26,35 @@ class IRemote(metaclass=ABCMeta):
|
||||
|
||||
def setter(self, param, val):
|
||||
"""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._cmd(param), val)
|
||||
|
||||
def _cmd(self, param):
|
||||
cmd = (self.identifier,)
|
||||
if param:
|
||||
cmd += (f".{param}",)
|
||||
return "".join(cmd)
|
||||
cmd += (f'.{param}',)
|
||||
return ''.join(cmd)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def identifier(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def public_packet(self):
|
||||
def public_packets(self):
|
||||
"""Returns an RT data packet."""
|
||||
return self._remote.public_packet
|
||||
return self._remote.public_packets
|
||||
|
||||
def apply(self, data):
|
||||
"""Sets all parameters of a dict for the channel."""
|
||||
|
||||
script = ''
|
||||
|
||||
def fget(attr, val):
|
||||
if attr == "mode":
|
||||
return (f"mode.{val}", 1)
|
||||
elif attr == "knob":
|
||||
return ("", val)
|
||||
if attr == 'mode':
|
||||
return (f'mode.{val}', 1)
|
||||
elif attr == 'knob':
|
||||
return ('', val)
|
||||
return (attr, val)
|
||||
|
||||
for attr, val in data.items():
|
||||
@@ -138,14 +65,9 @@ class IRemote(metaclass=ABCMeta):
|
||||
val = 1 if val else 0
|
||||
|
||||
self._remote.cache[self._cmd(attr)] = val
|
||||
self._remote._script += f"{self._cmd(attr)}={val};"
|
||||
script += f'{self._cmd(attr)}={val};'
|
||||
else:
|
||||
target = getattr(self, attr)
|
||||
target.apply(val)
|
||||
|
||||
self._remote.sendtext(self._remote._script)
|
||||
return self
|
||||
|
||||
def then_wait(self):
|
||||
self._remote._script = str()
|
||||
time.sleep(self._remote.DELAY)
|
||||
self._remote.sendtext(script)
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, unique
|
||||
|
||||
from .enums import KindId
|
||||
from .error import VBANCMDError
|
||||
|
||||
|
||||
@unique
|
||||
class KindId(Enum):
|
||||
BASIC = 1
|
||||
BANANA = 2
|
||||
POTATO = 3
|
||||
|
||||
|
||||
class SingletonType(type):
|
||||
"""ensure only a single instance of a kind map object"""
|
||||
|
||||
@@ -22,12 +15,15 @@ class SingletonType(type):
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class KindMapClass(metaclass=SingletonType):
|
||||
name: str
|
||||
ins: tuple
|
||||
outs: tuple
|
||||
vban: tuple
|
||||
strip_channels: int
|
||||
bus_channels: int
|
||||
cells: int
|
||||
|
||||
@property
|
||||
def phys_in(self):
|
||||
@@ -65,40 +61,49 @@ class KindMapClass(metaclass=SingletonType):
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class BasicMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (2, 1)
|
||||
outs: tuple = (1, 1)
|
||||
vban: tuple = (4, 4, 1, 1)
|
||||
strip_channels: int = 0
|
||||
bus_channels: int = 0
|
||||
cells: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class BananaMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (3, 2)
|
||||
outs: tuple = (3, 2)
|
||||
vban: tuple = (8, 8, 1, 1)
|
||||
strip_channels: int = 0
|
||||
bus_channels: int = 8
|
||||
cells: int = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class PotatoMap(KindMapClass):
|
||||
name: str
|
||||
ins: tuple = (5, 3)
|
||||
outs: tuple = (5, 3)
|
||||
vban: tuple = (8, 8, 1, 1)
|
||||
strip_channels: int = 2
|
||||
bus_channels: int = 8
|
||||
cells: int = 6
|
||||
|
||||
|
||||
def kind_factory(kind_id):
|
||||
match kind_id:
|
||||
case "basic":
|
||||
case 'basic':
|
||||
_kind_map = BasicMap
|
||||
case "banana":
|
||||
case 'banana':
|
||||
_kind_map = BananaMap
|
||||
case "potato":
|
||||
case 'potato':
|
||||
_kind_map = PotatoMap
|
||||
case _:
|
||||
raise ValueError(f"Unknown Voicemeeter kind {kind_id}")
|
||||
raise ValueError(f'Unknown Voicemeeter kind {kind_id}')
|
||||
return _kind_map(name=kind_id)
|
||||
|
||||
|
||||
@@ -111,4 +116,4 @@ def request_kind_map(kind_id):
|
||||
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)
|
||||
|
||||
@@ -5,32 +5,32 @@ 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}"
|
||||
return f'{type(self).__name__}{self._remote.kind}{self.index}'
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return f"command.button[{self.index}]"
|
||||
return f'command.button[{self.index}]'
|
||||
|
||||
@property
|
||||
def state(self) -> bool:
|
||||
self.logger.warning("button.state commands are not supported over VBAN")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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 .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):
|
||||
@@ -8,17 +10,25 @@ def channel_bool_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
return (
|
||||
not int.from_bytes(
|
||||
getattr(
|
||||
self.public_packet,
|
||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}state",
|
||||
)[self.index],
|
||||
"little",
|
||||
)
|
||||
& getattr(self._modes, f"_{param.lower()}")
|
||||
== 0
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
states = self.public_packets[NBS.zero].states
|
||||
channel_states = (
|
||||
states.strip if 'strip' in type(self).__name__.lower() else states.bus
|
||||
)
|
||||
channel_state = channel_states[self.index]
|
||||
|
||||
if param.lower() == 'mute':
|
||||
return channel_state.mute
|
||||
elif param.lower() == 'solo':
|
||||
return channel_state.solo
|
||||
elif param.lower() == 'mono':
|
||||
return channel_state.mono
|
||||
elif param.lower() == 'mc':
|
||||
return channel_state.mc
|
||||
else:
|
||||
return channel_state.get_mode(getattr(ChannelModes, param.upper()).value)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -26,18 +36,48 @@ def channel_bool_prop(param):
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def channel_int_prop(param):
|
||||
"""meta function for channel integer parameters"""
|
||||
|
||||
@partial(cache_int, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
states = self.public_packets[NBS.zero].states
|
||||
channel_states = (
|
||||
states.strip if 'strip' in type(self).__name__.lower() else states.bus
|
||||
)
|
||||
channel_state = channel_states[self.index]
|
||||
|
||||
# Special case: bus mono is an integer (0-2) encoded using bits 2 and 9
|
||||
if param.lower() == 'mono' and 'bus' in type(self).__name__.lower():
|
||||
bit_2 = (channel_state._state >> 2) & 1
|
||||
bit_9 = (channel_state._state >> 9) & 1
|
||||
return (bit_9 << 1) | bit_2
|
||||
else:
|
||||
return channel_state.get_mode_int(
|
||||
getattr(ChannelModes, param.upper()).value
|
||||
)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def channel_label_prop():
|
||||
"""meta function for channel label parameters"""
|
||||
|
||||
@partial(cache_string, param="label")
|
||||
@partial(cache_string, param='label')
|
||||
def fget(self) -> str:
|
||||
return getattr(
|
||||
self.public_packet,
|
||||
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}labels",
|
||||
)[self.index]
|
||||
if 'strip' in type(self).__name__.lower():
|
||||
return self.public_packets[NBS.zero].labels.strip[self.index]
|
||||
else:
|
||||
return self.public_packets[NBS.zero].labels.bus[self.index]
|
||||
|
||||
def fset(self, val: str):
|
||||
self.setter("label", str(val))
|
||||
self.setter('label', f'"{val}"')
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
@@ -47,11 +87,12 @@ def strip_output_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
return (
|
||||
not int.from_bytes(self.public_packet.stripstate[self.index], "little")
|
||||
& getattr(self._modes, f"_bus{param.lower()}")
|
||||
== 0
|
||||
)
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
strip_state = self.public_packets[NBS.zero].states.strip[self.index]
|
||||
|
||||
return strip_state.get_mode(getattr(ChannelModes, f'BUS{param.upper()}').value)
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -64,26 +105,17 @@ def bus_mode_prop(param):
|
||||
|
||||
@partial(cache_bool, param=param)
|
||||
def fget(self):
|
||||
modelist = {
|
||||
"amix": (1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1),
|
||||
"repeat": (0, 2, 2, 0, 0, 2, 2, 0, 0, 2, 2),
|
||||
"bmix": (1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3),
|
||||
"composite": (0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0),
|
||||
"tvmix": (1, 0, 1, 4, 5, 4, 5, 0, 1, 0, 1),
|
||||
"upmix21": (0, 2, 2, 4, 4, 6, 6, 0, 0, 2, 2),
|
||||
"upmix41": (1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3),
|
||||
"upmix61": (0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8),
|
||||
"centeronly": (1, 0, 1, 0, 1, 0, 1, 8, 9, 8, 9),
|
||||
"lfeonly": (0, 2, 2, 0, 0, 2, 2, 8, 8, 10, 10),
|
||||
"rearonly": (1, 2, 3, 0, 1, 2, 3, 8, 9, 10, 11),
|
||||
}
|
||||
vals = (
|
||||
int.from_bytes(self.public_packet.busstate[self.index], "little") & val
|
||||
for val in self._modes.modevals
|
||||
)
|
||||
if param == "normal":
|
||||
return not any(vals)
|
||||
return tuple(round(val / 16) for val in vals) == modelist[param]
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
|
||||
bus_state = self.public_packets[NBS.zero].states.bus[self.index]
|
||||
|
||||
# Extract current bus mode from bits 4-7
|
||||
current_mode = (bus_state._state & 0x000000F0) >> 4
|
||||
|
||||
expected_mode = getattr(BusModes, param.lower())
|
||||
|
||||
return current_mode == expected_mode
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, 1 if val else 0)
|
||||
@@ -98,3 +130,61 @@ def action_fn(param, val=1):
|
||||
self.setter(param, val)
|
||||
|
||||
return fdo
|
||||
|
||||
|
||||
def xy_prop(param):
|
||||
"""meta function for XY pad parameters"""
|
||||
|
||||
@partial(cache_float, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
|
||||
positions = self.public_packets[NBS.one].strips[self.index].positions
|
||||
match param:
|
||||
case 'pan_x':
|
||||
return positions.pan_x
|
||||
case 'pan_y':
|
||||
return positions.pan_y
|
||||
case 'color_x':
|
||||
return positions.color_x
|
||||
case 'color_y':
|
||||
return positions.color_y
|
||||
case 'fx1':
|
||||
return positions.fx1
|
||||
case 'fx2':
|
||||
return positions.fx2
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
|
||||
def send_prop(param):
|
||||
"""meta function for send parameters"""
|
||||
|
||||
@partial(cache_float, param=param)
|
||||
def fget(self):
|
||||
cmd = self._cmd(param)
|
||||
self.logger.debug(f'getter: {cmd}')
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
|
||||
sends = self.public_packets[NBS.one].strips[self.index].sends
|
||||
match param:
|
||||
case 'reverb':
|
||||
return sends.reverb
|
||||
case 'delay':
|
||||
return sends.delay
|
||||
case 'fx1':
|
||||
return sends.fx1
|
||||
case 'fx2':
|
||||
return sends.fx2
|
||||
|
||||
def fset(self, val):
|
||||
self.setter(param, val)
|
||||
|
||||
return property(fget, fset)
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .kinds import KindMapClass
|
||||
from .util import comp
|
||||
|
||||
VBAN_PROTOCOL_TXT = 0x40
|
||||
VBAN_PROTOCOL_SERVICE = 0x60
|
||||
|
||||
VBAN_SERVICE_RTPACKETREGISTER = 32
|
||||
VBAN_SERVICE_RTPACKET = 33
|
||||
|
||||
MAX_PACKET_SIZE = 1436
|
||||
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRtPacket:
|
||||
"""Represents the body of a VBAN RT data packet"""
|
||||
|
||||
_kind: KindMapClass
|
||||
_voicemeeterType: bytes # data[28:29]
|
||||
_reserved: bytes # data[29:30]
|
||||
_buffersize: bytes # data[30:32]
|
||||
_voicemeeterVersion: bytes # data[32:36]
|
||||
_optionBits: bytes # data[36:40]
|
||||
_samplerate: bytes # data[40:44]
|
||||
_inputLeveldB100: bytes # data[44:112]
|
||||
_outputLeveldB100: bytes # data[112:240]
|
||||
_TransportBit: bytes # data[240:244]
|
||||
_stripState: bytes # data[244:276]
|
||||
_busState: bytes # data[276:308]
|
||||
_stripGaindB100Layer1: bytes # data[308:324]
|
||||
_stripGaindB100Layer2: bytes # data[324:340]
|
||||
_stripGaindB100Layer3: bytes # data[340:356]
|
||||
_stripGaindB100Layer4: bytes # data[356:372]
|
||||
_stripGaindB100Layer5: bytes # data[372:388]
|
||||
_stripGaindB100Layer6: bytes # data[388:404]
|
||||
_stripGaindB100Layer7: bytes # data[404:420]
|
||||
_stripGaindB100Layer8: bytes # data[420:436]
|
||||
_busGaindB100: bytes # data[436:452]
|
||||
_stripLabelUTF8c60: bytes # data[452:932]
|
||||
_busLabelUTF8c60: bytes # data[932:1412]
|
||||
|
||||
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 : self._kind.num_strip_levels]
|
||||
|
||||
@property
|
||||
def outputlevels(self) -> tuple:
|
||||
"""returns the entire level array across all outputs for a kind"""
|
||||
return self.bus_levels[0 : self._kind.num_bus_levels]
|
||||
|
||||
@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 SubscribeHeader:
|
||||
"""Represents the header an RT Packet Service subscription packet"""
|
||||
|
||||
name = "Register RTP"
|
||||
timeout = 15
|
||||
vban: bytes = "VBAN".encode()
|
||||
format_sr: bytes = (VBAN_PROTOCOL_SERVICE).to_bytes(1, "little")
|
||||
format_nbs: bytes = (0).to_bytes(1, "little")
|
||||
format_nbc: bytes = (VBAN_SERVICE_RTPACKETREGISTER).to_bytes(1, "little")
|
||||
format_bit: bytes = (timeout & 0x000000FF).to_bytes(1, "little") # timeout
|
||||
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
|
||||
framecounter: bytes = (0).to_bytes(4, "little")
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
header = self.vban
|
||||
header += self.format_sr
|
||||
header += self.format_nbs
|
||||
header += self.format_nbc
|
||||
header += self.format_bit
|
||||
header += self.streamname
|
||||
header += self.framecounter
|
||||
assert (
|
||||
len(header) == HEADER_SIZE + 4
|
||||
), f"expected header size {HEADER_SIZE} bytes + 4 bytes framecounter ({HEADER_SIZE +4} bytes total)"
|
||||
return header
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRtPacketHeader:
|
||||
"""Represents the header of a VBAN RT response packet"""
|
||||
|
||||
name = "Voicemeeter-RTP"
|
||||
vban: bytes = "VBAN".encode()
|
||||
format_sr: bytes = (VBAN_PROTOCOL_SERVICE).to_bytes(1, "little")
|
||||
format_nbs: bytes = (0).to_bytes(1, "little")
|
||||
format_nbc: bytes = (VBAN_SERVICE_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, f"expected header size {HEADER_SIZE} bytes"
|
||||
return header
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestHeader:
|
||||
"""Represents the header of an REQUEST RT PACKET"""
|
||||
|
||||
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 (VBAN_PROTOCOL_TXT + 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 + 4
|
||||
), f"expected header size {HEADER_SIZE} bytes + 4 bytes framecounter ({HEADER_SIZE +4} bytes total)"
|
||||
return header
|
||||
83
vban_cmd/packet/enums.py
Normal file
83
vban_cmd/packet/enums.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from enum import Flag
|
||||
|
||||
|
||||
class SubProtocols(Flag):
|
||||
"""Sub Protocols - Bit flags that can be combined"""
|
||||
|
||||
AUDIO = 0x00
|
||||
SERIAL = 0x20
|
||||
TEXT = 0x40
|
||||
SERVICE = 0x60
|
||||
MASK = 0xE0
|
||||
|
||||
|
||||
class ServiceTypes(Flag):
|
||||
"""Service Types - Bit flags that can be combined"""
|
||||
|
||||
PING = 0
|
||||
PONG = 0
|
||||
CHATUTF8 = 1
|
||||
RTPACKETREGISTER = 32
|
||||
RTPACKET = 33
|
||||
REQUESTREPLY = 0x02 # A Matrix reply
|
||||
FNCT_REPLY = 0x80 # An RTPacket reply
|
||||
|
||||
|
||||
class StreamTypes(Flag):
|
||||
"""Stream Types - Bit flags that can be combined"""
|
||||
|
||||
ASCII = 0x00
|
||||
UTF8 = 0x10
|
||||
WCHAR = 0x20
|
||||
|
||||
|
||||
class ChannelModes(Flag):
|
||||
"""Channel Modes - Bit flags that can be combined"""
|
||||
|
||||
MUTE = 0x00000001
|
||||
SOLO = 0x00000002
|
||||
MONO = 0x00000004
|
||||
MC = 0x00000008
|
||||
|
||||
AMIX = 0x00000010
|
||||
REPEAT = 0x00000020
|
||||
BMIX = 0x00000030
|
||||
COMPOSITE = 0x00000040
|
||||
TVMIX = 0x00000050
|
||||
UPMIX21 = 0x00000060
|
||||
UPMIX41 = 0x00000070
|
||||
UPMIX61 = 0x00000080
|
||||
CENTERONLY = 0x00000090
|
||||
LFEONLY = 0x000000A0
|
||||
REARONLY = 0x000000B0
|
||||
|
||||
MASK = 0x000000F0
|
||||
|
||||
ON = 0x00000100 # eq.on
|
||||
CROSS = 0x00000200
|
||||
AB = 0x00000800 # eq.ab
|
||||
|
||||
BUSA = 0x00001000
|
||||
BUSA1 = 0x00001000
|
||||
BUSA2 = 0x00002000
|
||||
BUSA3 = 0x00004000
|
||||
BUSA4 = 0x00008000
|
||||
BUSA5 = 0x00080000
|
||||
|
||||
BUSB = 0x00010000
|
||||
BUSB1 = 0x00010000
|
||||
BUSB2 = 0x00020000
|
||||
BUSB3 = 0x00040000
|
||||
|
||||
PAN0 = 0x00000000
|
||||
PANCOLOR = 0x00100000
|
||||
PANMOD = 0x00200000
|
||||
PANMASK = 0x00F00000
|
||||
|
||||
POSTFX_R = 0x01000000
|
||||
POSTFX_D = 0x02000000
|
||||
POSTFX1 = 0x04000000
|
||||
POSTFX2 = 0x08000000
|
||||
|
||||
SEL = 0x10000000
|
||||
MONITOR = 0x20000000
|
||||
349
vban_cmd/packet/headers.py
Normal file
349
vban_cmd/packet/headers.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
|
||||
from .enums import ServiceTypes, StreamTypes, SubProtocols
|
||||
|
||||
PINGPONG_PACKET_SIZE = 704 # Size of the PING/PONG header + payload in bytes
|
||||
MAX_PACKET_SIZE = 1436
|
||||
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPingHeader:
|
||||
"""Represents the header of a PING packet"""
|
||||
|
||||
name: str = 'PING0'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.PING.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls, framecounter: int = 0) -> bytes:
|
||||
"""Creates the PING header bytes only."""
|
||||
header = cls(framecounter=framecounter)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.format_sr,
|
||||
header.format_nbs,
|
||||
header.format_nbc,
|
||||
header.format_bit,
|
||||
header.streamname,
|
||||
header.framecounter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPongHeader:
|
||||
"""Represents the header of a PONG response packet"""
|
||||
|
||||
name: str = 'PING0'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.PONG.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a PONG response packet from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# PONG responses use the same service type as PING (0x00)
|
||||
# and are identified by having payload data
|
||||
if parsed['format_nbc'] != ServiceTypes.PONG.value:
|
||||
raise ValueError(f'Not a PONG response packet: {parsed["format_nbc"]:02x}')
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
@classmethod
|
||||
def is_pong_response(cls, data: bytes) -> bool:
|
||||
"""Check if packet is a PONG response by analyzing the actual response format."""
|
||||
try:
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is a service protocol packet with PING/PONG service type
|
||||
if parsed['format_nbc'] != ServiceTypes.PONG.value:
|
||||
return False
|
||||
|
||||
if parsed['name'] not in ['PING0', 'VBAN Service']:
|
||||
return False
|
||||
|
||||
# PONG should have payload data (same size as PING)
|
||||
return len(data) >= PINGPONG_PACKET_SIZE
|
||||
|
||||
except (ValueError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacket:
|
||||
"""Represents the header of an incoming RTPacket"""
|
||||
|
||||
nbs: NBS
|
||||
_kind: KindMapClass
|
||||
_voicemeeterType: bytes
|
||||
_reserved: bytes
|
||||
_buffersize: bytes
|
||||
_voicemeeterVersion: bytes
|
||||
_optionBits: bytes
|
||||
_samplerate: bytes
|
||||
|
||||
@property
|
||||
def voicemeetertype(self) -> str:
|
||||
"""returns voicemeeter type as a string"""
|
||||
return ['', 'basic', 'banana', 'potato'][
|
||||
int.from_bytes(self._voicemeeterType, 'little')
|
||||
]
|
||||
|
||||
@property
|
||||
def voicemeeterversion(self) -> tuple:
|
||||
"""returns voicemeeter version as a tuple"""
|
||||
return tuple(self._voicemeeterVersion[i] for i in range(3, -1, -1))
|
||||
|
||||
@property
|
||||
def samplerate(self) -> int:
|
||||
"""returns samplerate as an int"""
|
||||
return int.from_bytes(self._samplerate, 'little')
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTSubscribeHeader:
|
||||
"""Represents the header of an RT subscription packet"""
|
||||
|
||||
nbs: NBS = NBS.zero
|
||||
name: str = 'Register-RTP'
|
||||
timeout: int = 15
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def format_sr(self) -> bytes:
|
||||
return SubProtocols.SERVICE.value.to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_nbs(self) -> bytes:
|
||||
return (self.nbs.value & 0xFF).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_nbc(self) -> bytes:
|
||||
return ServiceTypes.RTPACKETREGISTER.value.to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def format_bit(self) -> bytes:
|
||||
return (self.timeout & 0xFF).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii') + bytes(16 - len(self.name))
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls, nbs: NBS, framecounter: int) -> bytes:
|
||||
header = cls(nbs=nbs)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.format_sr[0],
|
||||
header.format_nbs[0],
|
||||
header.format_nbc[0],
|
||||
header.format_bit[0],
|
||||
header.streamname,
|
||||
framecounter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTRequestHeader:
|
||||
"""Represents the header of an RT request packet"""
|
||||
|
||||
name: str
|
||||
bps_index: int
|
||||
channel: int
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def sr(self) -> bytes:
|
||||
return (self.bps_index | SubProtocols.TEXT.value).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def nbs(self) -> bytes:
|
||||
return (0).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def nbc(self) -> bytes:
|
||||
return (self.channel).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def bit(self) -> bytes:
|
||||
return (StreamTypes.UTF8.value).to_bytes(1, 'little')
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode()[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(
|
||||
cls, name: str, bps_index: int, channel: int, framecounter: int
|
||||
) -> bytes:
|
||||
header = cls(
|
||||
name=name, bps_index=bps_index, channel=channel, framecounter=framecounter
|
||||
)
|
||||
|
||||
return struct.pack(
|
||||
'<4s4B16sI',
|
||||
header.vban,
|
||||
header.sr[0],
|
||||
header.nbs[0],
|
||||
header.nbc[0],
|
||||
header.bit[0],
|
||||
header.streamname,
|
||||
header.framecounter,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def encode_with_payload(
|
||||
cls, name: str, bps_index: int, channel: int, framecounter: int, payload: str
|
||||
) -> bytes:
|
||||
"""Creates the complete packet with header and payload."""
|
||||
return cls.to_bytes(name, bps_index, channel, framecounter) + payload.encode()
|
||||
|
||||
|
||||
def _parse_vban_service_header(data: bytes) -> dict:
|
||||
"""Common parsing and validation for VBAN service protocol headers."""
|
||||
if len(data) < HEADER_SIZE:
|
||||
raise ValueError('Data is too short to be a valid VBAN header')
|
||||
|
||||
if data[:4] != b'VBAN':
|
||||
raise ValueError('Invalid VBAN magic bytes')
|
||||
|
||||
format_sr = data[4]
|
||||
format_nbs = data[5]
|
||||
format_nbc = data[6]
|
||||
format_bit = data[7]
|
||||
|
||||
# Verify this is a service protocol packet
|
||||
protocol = format_sr & SubProtocols.MASK.value
|
||||
if protocol != SubProtocols.SERVICE.value:
|
||||
raise ValueError(f'Not a service protocol packet: {protocol:02x}')
|
||||
|
||||
# Extract stream name and frame counter
|
||||
name = data[8:24].rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
framecounter = int.from_bytes(data[24:28], 'little')
|
||||
|
||||
return {
|
||||
'format_sr': format_sr,
|
||||
'format_nbs': format_nbs,
|
||||
'format_nbc': format_nbc,
|
||||
'format_bit': format_bit,
|
||||
'name': name,
|
||||
'framecounter': framecounter,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTResponseHeader:
|
||||
"""Represents the header of an RT response packet"""
|
||||
|
||||
name: str = 'Voicemeeter-RTP'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = 0
|
||||
format_nbc: int = ServiceTypes.RTPACKET.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii') + bytes(16 - len(self.name))
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a VbanResponseHeader from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is an RTPacket response
|
||||
if parsed['format_nbc'] != ServiceTypes.RTPACKET.value:
|
||||
raise ValueError(
|
||||
f'Not an RTPacket response packet: {parsed["format_nbc"]:02x}'
|
||||
)
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanMatrixResponseHeader:
|
||||
"""Represents the header of a matrix response packet"""
|
||||
|
||||
name: str = 'Request Reply'
|
||||
format_sr: int = SubProtocols.SERVICE.value
|
||||
format_nbs: int = ServiceTypes.FNCT_REPLY.value
|
||||
format_nbc: int = ServiceTypes.REQUESTREPLY.value
|
||||
format_bit: int = 0
|
||||
framecounter: int = 0
|
||||
|
||||
@property
|
||||
def vban(self) -> bytes:
|
||||
return b'VBAN'
|
||||
|
||||
@property
|
||||
def streamname(self) -> bytes:
|
||||
return self.name.encode('ascii')[:16].ljust(16, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
"""Parse a matrix response packet from bytes."""
|
||||
parsed = _parse_vban_service_header(data)
|
||||
|
||||
# Validate this is a service reply packet
|
||||
if parsed['format_nbs'] != ServiceTypes.FNCT_REPLY.value:
|
||||
raise ValueError(f'Not a service reply packet: {parsed["format_nbs"]:02x}')
|
||||
|
||||
return cls(**parsed)
|
||||
|
||||
@classmethod
|
||||
def extract_payload(cls, data: bytes) -> str:
|
||||
"""Extract the text payload from a matrix response packet."""
|
||||
if len(data) <= HEADER_SIZE:
|
||||
return ''
|
||||
|
||||
payload_bytes = data[HEADER_SIZE:]
|
||||
return payload_bytes.rstrip(b'\x00').decode('utf-8', errors='ignore')
|
||||
|
||||
@classmethod
|
||||
def parse_response(cls, data: bytes) -> tuple['VbanMatrixResponseHeader', str]:
|
||||
"""Parse a complete matrix response packet returning header and payload."""
|
||||
header = cls.from_bytes(data)
|
||||
payload = cls.extract_payload(data)
|
||||
return header, payload
|
||||
260
vban_cmd/packet/nbs0.py
Normal file
260
vban_cmd/packet/nbs0.py
Normal file
@@ -0,0 +1,260 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import NamedTuple
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
from vban_cmd.util import comp
|
||||
|
||||
from .enums import ChannelModes
|
||||
from .headers import VbanRTPacket
|
||||
|
||||
|
||||
class Levels(NamedTuple):
|
||||
strip: tuple[float, ...]
|
||||
bus: tuple[float, ...]
|
||||
|
||||
|
||||
class ChannelState:
|
||||
"""Represents the processed state of a single strip or bus channel"""
|
||||
|
||||
def __init__(self, state_bytes: bytes):
|
||||
# Convert 4-byte state to integer once for efficient lookups
|
||||
self._state = int.from_bytes(state_bytes, 'little')
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, state_int: int):
|
||||
"""Create ChannelState directly from integer for efficiency"""
|
||||
instance = cls.__new__(cls)
|
||||
instance._state = state_int
|
||||
return instance
|
||||
|
||||
def get_mode(self, mode_value: int) -> bool:
|
||||
"""Get boolean state for a specific mode"""
|
||||
return (self._state & mode_value) != 0
|
||||
|
||||
def get_mode_int(self, mode_value: int) -> int:
|
||||
"""Get integer state for a specific mode"""
|
||||
return self._state & mode_value
|
||||
|
||||
# Common boolean modes
|
||||
@property
|
||||
def mute(self) -> bool:
|
||||
return (self._state & ChannelModes.MUTE.value) != 0
|
||||
|
||||
@property
|
||||
def solo(self) -> bool:
|
||||
return (self._state & ChannelModes.SOLO.value) != 0
|
||||
|
||||
@property
|
||||
def mono(self) -> bool:
|
||||
return (self._state & ChannelModes.MONO.value) != 0
|
||||
|
||||
@property
|
||||
def mc(self) -> bool:
|
||||
return (self._state & ChannelModes.MC.value) != 0
|
||||
|
||||
# EQ modes
|
||||
@property
|
||||
def eq_on(self) -> bool:
|
||||
return (self._state & ChannelModes.ON.value) != 0
|
||||
|
||||
@property
|
||||
def eq_ab(self) -> bool:
|
||||
return (self._state & ChannelModes.AB.value) != 0
|
||||
|
||||
# Bus assignments (strip to bus routing)
|
||||
@property
|
||||
def busa1(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA1.value) != 0
|
||||
|
||||
@property
|
||||
def busa2(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA2.value) != 0
|
||||
|
||||
@property
|
||||
def busa3(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA3.value) != 0
|
||||
|
||||
@property
|
||||
def busa4(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSA4.value) != 0
|
||||
|
||||
@property
|
||||
def busb1(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB1.value) != 0
|
||||
|
||||
@property
|
||||
def busb2(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB2.value) != 0
|
||||
|
||||
@property
|
||||
def busb3(self) -> bool:
|
||||
return (self._state & ChannelModes.BUSB3.value) != 0
|
||||
|
||||
|
||||
class States(NamedTuple):
|
||||
strip: tuple[ChannelState, ...]
|
||||
bus: tuple[ChannelState, ...]
|
||||
|
||||
|
||||
class Labels(NamedTuple):
|
||||
strip: tuple[str, ...]
|
||||
bus: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacketNBS0(VbanRTPacket):
|
||||
"""Represents the body of a VBAN RTPacket with ident:0"""
|
||||
|
||||
_inputLeveldB100: bytes
|
||||
_outputLeveldB100: bytes
|
||||
_TransportBit: bytes
|
||||
_stripState: bytes
|
||||
_busState: bytes
|
||||
_stripGaindB100Layer1: bytes
|
||||
_stripGaindB100Layer2: bytes
|
||||
_stripGaindB100Layer3: bytes
|
||||
_stripGaindB100Layer4: bytes
|
||||
_stripGaindB100Layer5: bytes
|
||||
_stripGaindB100Layer6: bytes
|
||||
_stripGaindB100Layer7: bytes
|
||||
_stripGaindB100Layer8: bytes
|
||||
_busGaindB100: bytes
|
||||
_stripLabelUTF8c60: bytes
|
||||
_busLabelUTF8c60: bytes
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, nbs: NBS, kind: KindMapClass, data: bytes):
|
||||
return cls(
|
||||
nbs=nbs,
|
||||
_kind=kind,
|
||||
_voicemeeterType=data[28:29],
|
||||
_reserved=data[29:30],
|
||||
_buffersize=data[30:32],
|
||||
_voicemeeterVersion=data[32:36],
|
||||
_optionBits=data[36:40],
|
||||
_samplerate=data[40:44],
|
||||
_inputLeveldB100=data[44:112],
|
||||
_outputLeveldB100=data[112:240],
|
||||
_TransportBit=data[240:244],
|
||||
_stripState=data[244:276],
|
||||
_busState=data[276:308],
|
||||
_stripGaindB100Layer1=data[308:324],
|
||||
_stripGaindB100Layer2=data[324:340],
|
||||
_stripGaindB100Layer3=data[340:356],
|
||||
_stripGaindB100Layer4=data[356:372],
|
||||
_stripGaindB100Layer5=data[372:388],
|
||||
_stripGaindB100Layer6=data[388:404],
|
||||
_stripGaindB100Layer7=data[404:420],
|
||||
_stripGaindB100Layer8=data[420:436],
|
||||
_busGaindB100=data[436:452],
|
||||
_stripLabelUTF8c60=data[452:932],
|
||||
_busLabelUTF8c60=data[932:1412],
|
||||
)
|
||||
|
||||
def pdirty(self, other) -> bool:
|
||||
"""True iff any defined parameter has changed"""
|
||||
return (
|
||||
self._stripState != other._stripState
|
||||
or self._busState != other._busState
|
||||
or self._stripGaindB100Layer1 != other._stripGaindB100Layer1
|
||||
or self._stripGaindB100Layer2 != other._stripGaindB100Layer2
|
||||
or self._stripGaindB100Layer3 != other._stripGaindB100Layer3
|
||||
or self._stripGaindB100Layer4 != other._stripGaindB100Layer4
|
||||
or self._stripGaindB100Layer5 != other._stripGaindB100Layer5
|
||||
or self._stripGaindB100Layer6 != other._stripGaindB100Layer6
|
||||
or self._stripGaindB100Layer7 != other._stripGaindB100Layer7
|
||||
or self._stripGaindB100Layer8 != other._stripGaindB100Layer8
|
||||
or self._busGaindB100 != other._busGaindB100
|
||||
or self._stripLabelUTF8c60 != other._stripLabelUTF8c60
|
||||
or self._busLabelUTF8c60 != other._busLabelUTF8c60
|
||||
)
|
||||
|
||||
def ldirty(self, strip_cache, bus_cache) -> bool:
|
||||
"""True iff any level has changed, ignoring changes when levels are very quiet"""
|
||||
self._strip_comp, self._bus_comp = (
|
||||
tuple(not val for val in comp(strip_cache, self.strip_levels)),
|
||||
tuple(not val for val in comp(bus_cache, self.bus_levels)),
|
||||
)
|
||||
return any(self._strip_comp) or any(self._bus_comp)
|
||||
|
||||
@cached_property
|
||||
def strip_levels(self) -> tuple[float, ...]:
|
||||
"""Returns strip levels in dB"""
|
||||
strip_raw = struct.unpack('<34h', self._inputLeveldB100)
|
||||
return tuple(round(val * 0.01, 1) for val in strip_raw)[
|
||||
: self._kind.num_strip_levels
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def bus_levels(self) -> tuple[float, ...]:
|
||||
"""Returns bus levels in dB"""
|
||||
bus_raw = struct.unpack('<64h', self._outputLeveldB100)
|
||||
return tuple(round(val * 0.01, 1) for val in bus_raw)[
|
||||
: self._kind.num_bus_levels
|
||||
]
|
||||
|
||||
@property
|
||||
def levels(self) -> Levels:
|
||||
"""Returns strip and bus levels as a namedtuple"""
|
||||
return Levels(strip=self.strip_levels, bus=self.bus_levels)
|
||||
|
||||
@cached_property
|
||||
def states(self) -> States:
|
||||
"""returns States object with processed strip and bus channel states"""
|
||||
strip_states = struct.unpack('<8I', self._stripState)
|
||||
bus_states = struct.unpack('<8I', self._busState)
|
||||
return States(
|
||||
strip=tuple(ChannelState.from_int(state) for state in strip_states),
|
||||
bus=tuple(ChannelState.from_int(state) for state in bus_states),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def gainlayers(self) -> tuple:
|
||||
"""returns tuple of all strip gain layers as tuples"""
|
||||
layer_data = []
|
||||
for layer in range(1, 9):
|
||||
layer_bytes = getattr(self, f'_stripGaindB100Layer{layer}')
|
||||
layer_raw = struct.unpack('<8h', layer_bytes)
|
||||
layer_data.append(tuple(round(val * 0.01, 2) for val in layer_raw))
|
||||
return tuple(layer_data)
|
||||
|
||||
@cached_property
|
||||
def busgain(self) -> tuple:
|
||||
"""returns tuple of bus gains"""
|
||||
bus_gain_raw = struct.unpack('<8h', self._busGaindB100)
|
||||
return tuple(round(val * 0.01, 2) for val in bus_gain_raw)
|
||||
|
||||
@cached_property
|
||||
def labels(self) -> Labels:
|
||||
"""returns Labels namedtuple of strip and bus labels"""
|
||||
|
||||
def _extract_labels_from_bytes(label_bytes: bytes) -> tuple[str, ...]:
|
||||
"""Extract null-terminated UTF-8 labels from 60-byte chunks"""
|
||||
labels = []
|
||||
for i in range(0, len(label_bytes), 60):
|
||||
chunk = label_bytes[i : i + 60]
|
||||
null_pos = chunk.find(b'\x00')
|
||||
if null_pos == -1:
|
||||
try:
|
||||
label = chunk.decode('utf-8', errors='replace').rstrip('\x00')
|
||||
except UnicodeDecodeError:
|
||||
label = ''
|
||||
else:
|
||||
try:
|
||||
label = (
|
||||
chunk[:null_pos].decode('utf-8', errors='replace')
|
||||
if null_pos > 0
|
||||
else ''
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
label = ''
|
||||
labels.append(label)
|
||||
return tuple(labels)
|
||||
|
||||
return Labels(
|
||||
strip=_extract_labels_from_bytes(self._stripLabelUTF8c60),
|
||||
bus=_extract_labels_from_bytes(self._busLabelUTF8c60),
|
||||
)
|
||||
358
vban_cmd/packet/nbs1.py
Normal file
358
vban_cmd/packet/nbs1.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import NamedTuple
|
||||
|
||||
from vban_cmd.enums import NBS
|
||||
from vban_cmd.kinds import KindMapClass
|
||||
|
||||
from .headers import VbanRTPacket
|
||||
|
||||
VMPARAMSTRIP_SIZE = 174
|
||||
|
||||
|
||||
class Audibility(NamedTuple):
|
||||
knob: float
|
||||
comp: float
|
||||
gate: float
|
||||
denoiser: float
|
||||
|
||||
|
||||
class Positions(NamedTuple):
|
||||
pan_x: float
|
||||
pan_y: float
|
||||
color_x: float
|
||||
color_y: float
|
||||
fx1: float
|
||||
fx2: float
|
||||
|
||||
|
||||
class EqGains(NamedTuple):
|
||||
bass: float
|
||||
mid: float
|
||||
treble: float
|
||||
|
||||
|
||||
class ParametricEQSettings(NamedTuple):
|
||||
on: bool
|
||||
type: int
|
||||
gain: float
|
||||
freq: float
|
||||
q: float
|
||||
|
||||
|
||||
class Sends(NamedTuple):
|
||||
reverb: float
|
||||
delay: float
|
||||
fx1: float
|
||||
fx2: float
|
||||
|
||||
|
||||
class CompressorSettings(NamedTuple):
|
||||
gain_in: float
|
||||
attack_ms: float
|
||||
release_ms: float
|
||||
n_knee: float
|
||||
ratio: float
|
||||
threshold: float
|
||||
c_enabled: bool
|
||||
makeup: bool
|
||||
gain_out: float
|
||||
|
||||
|
||||
class GateSettings(NamedTuple):
|
||||
threshold_in: float
|
||||
damping_max: float
|
||||
bp_sidechain: bool
|
||||
attack_ms: float
|
||||
hold_ms: float
|
||||
release_ms: float
|
||||
|
||||
|
||||
class DenoiserSettings(NamedTuple):
|
||||
threshold: float
|
||||
|
||||
|
||||
class PitchSettings(NamedTuple):
|
||||
enabled: bool
|
||||
dry_wet: float
|
||||
value: float
|
||||
formant_lo: float
|
||||
formant_med: float
|
||||
formant_high: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanVMParamStrip:
|
||||
"""Represents the VBAN_VMPARAMSTRIP_PACKET structure"""
|
||||
|
||||
_mode: bytes
|
||||
_dblevel: bytes
|
||||
_audibility: bytes
|
||||
_pos3D_x: bytes
|
||||
_pos3D_y: bytes
|
||||
_posColor_x: bytes
|
||||
_posColor_y: bytes
|
||||
_EQgain1: bytes
|
||||
_EQgain2: bytes
|
||||
_EQgain3: bytes
|
||||
|
||||
# First channel parametric EQ
|
||||
_PEQ_eqOn: bytes
|
||||
_PEQ_eqtype: bytes
|
||||
_PEQ_eqgain: bytes
|
||||
_PEQ_eqfreq: bytes
|
||||
_PEQ_eqq: bytes
|
||||
|
||||
_audibility_c: bytes
|
||||
_audibility_g: bytes
|
||||
_audibility_d: bytes
|
||||
_posMod_x: bytes
|
||||
_posMod_y: bytes
|
||||
_send_reverb: bytes
|
||||
_send_delay: bytes
|
||||
_send_fx1: bytes
|
||||
_send_fx2: bytes
|
||||
_dblimit: bytes
|
||||
_nKaraoke: bytes
|
||||
|
||||
_COMP_gain_in: bytes
|
||||
_COMP_attack_ms: bytes
|
||||
_COMP_release_ms: bytes
|
||||
_COMP_n_knee: bytes
|
||||
_COMP_comprate: bytes
|
||||
_COMP_threshold: bytes
|
||||
_COMP_c_enabled: bytes
|
||||
_COMP_c_auto: bytes
|
||||
_COMP_gain_out: bytes
|
||||
|
||||
_GATE_dBThreshold_in: bytes
|
||||
_GATE_dBDamping_max: bytes
|
||||
_GATE_BP_Sidechain: bytes
|
||||
_GATE_attack_ms: bytes
|
||||
_GATE_hold_ms: bytes
|
||||
_GATE_release_ms: bytes
|
||||
|
||||
_DenoiserThreshold: bytes
|
||||
_PitchEnabled: bytes
|
||||
_Pitch_DryWet: bytes
|
||||
_Pitch_Value: bytes
|
||||
_Pitch_formant_lo: bytes
|
||||
_Pitch_formant_med: bytes
|
||||
_Pitch_formant_high: bytes
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
return cls(
|
||||
_mode=data[0:4],
|
||||
_dblevel=data[4:8],
|
||||
_audibility=data[8:10],
|
||||
_pos3D_x=data[10:12],
|
||||
_pos3D_y=data[12:14],
|
||||
_posColor_x=data[14:16],
|
||||
_posColor_y=data[16:18],
|
||||
_EQgain1=data[18:20],
|
||||
_EQgain2=data[20:22],
|
||||
_EQgain3=data[22:24],
|
||||
_PEQ_eqOn=data[24:30],
|
||||
_PEQ_eqtype=data[30:36],
|
||||
_PEQ_eqgain=data[36:60],
|
||||
_PEQ_eqfreq=data[60:84],
|
||||
_PEQ_eqq=data[84:108],
|
||||
_audibility_c=data[108:110],
|
||||
_audibility_g=data[110:112],
|
||||
_audibility_d=data[112:114],
|
||||
_posMod_x=data[114:116],
|
||||
_posMod_y=data[116:118],
|
||||
_send_reverb=data[118:120],
|
||||
_send_delay=data[120:122],
|
||||
_send_fx1=data[122:124],
|
||||
_send_fx2=data[124:126],
|
||||
_dblimit=data[126:128],
|
||||
_nKaraoke=data[128:130],
|
||||
_COMP_gain_in=data[130:132],
|
||||
_COMP_attack_ms=data[132:134],
|
||||
_COMP_release_ms=data[134:136],
|
||||
_COMP_n_knee=data[136:138],
|
||||
_COMP_comprate=data[138:140],
|
||||
_COMP_threshold=data[140:142],
|
||||
_COMP_c_enabled=data[142:144],
|
||||
_COMP_c_auto=data[144:146],
|
||||
_COMP_gain_out=data[146:148],
|
||||
_GATE_dBThreshold_in=data[148:150],
|
||||
_GATE_dBDamping_max=data[150:152],
|
||||
_GATE_BP_Sidechain=data[152:154],
|
||||
_GATE_attack_ms=data[154:156],
|
||||
_GATE_hold_ms=data[156:158],
|
||||
_GATE_release_ms=data[158:160],
|
||||
_DenoiserThreshold=data[160:162],
|
||||
_PitchEnabled=data[162:164],
|
||||
_Pitch_DryWet=data[164:166],
|
||||
_Pitch_Value=data[166:168],
|
||||
_Pitch_formant_lo=data[168:170],
|
||||
_Pitch_formant_med=data[170:172],
|
||||
_Pitch_formant_high=data[172:174],
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def mode(self) -> int:
|
||||
return int.from_bytes(self._mode, 'little')
|
||||
|
||||
@cached_property
|
||||
def karaoke(self) -> int:
|
||||
return int.from_bytes(self._nKaraoke, 'little')
|
||||
|
||||
@cached_property
|
||||
def audibility(self) -> Audibility:
|
||||
return Audibility(
|
||||
round(int.from_bytes(self._audibility, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_c, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_g, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._audibility_d, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def positions(self) -> Positions:
|
||||
return Positions(
|
||||
round(int.from_bytes(self._pos3D_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._pos3D_y, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posColor_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posColor_y, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posMod_x, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._posMod_y, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def eqgains(self) -> EqGains:
|
||||
return EqGains(
|
||||
*[
|
||||
round(
|
||||
int.from_bytes(getattr(self, f'_EQgain{i}'), 'little', signed=True)
|
||||
* 0.01,
|
||||
2,
|
||||
)
|
||||
for i in range(1, 4)
|
||||
]
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def parametric_eq(self) -> tuple[ParametricEQSettings, ...]:
|
||||
return tuple(
|
||||
ParametricEQSettings(
|
||||
on=bool(int.from_bytes(self._PEQ_eqOn[i : i + 1], 'little')),
|
||||
type=int.from_bytes(self._PEQ_eqtype[i : i + 1], 'little'),
|
||||
freq=struct.unpack('<f', self._PEQ_eqfreq[i * 4 : (i + 1) * 4])[0],
|
||||
gain=struct.unpack('<f', self._PEQ_eqgain[i * 4 : (i + 1) * 4])[0],
|
||||
q=struct.unpack('<f', self._PEQ_eqq[i * 4 : (i + 1) * 4])[0],
|
||||
)
|
||||
for i in range(6)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def sends(self) -> Sends:
|
||||
return Sends(
|
||||
round(int.from_bytes(self._send_reverb, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_delay, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_fx1, 'little', signed=True) * 0.01, 2),
|
||||
round(int.from_bytes(self._send_fx2, 'little', signed=True) * 0.01, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def compressor(self) -> CompressorSettings:
|
||||
return CompressorSettings(
|
||||
gain_in=round(
|
||||
int.from_bytes(self._COMP_gain_in, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
attack_ms=round(int.from_bytes(self._COMP_attack_ms, 'little') * 0.1, 2),
|
||||
release_ms=round(int.from_bytes(self._COMP_release_ms, 'little') * 0.1, 2),
|
||||
n_knee=round(int.from_bytes(self._COMP_n_knee, 'little') * 0.01, 2),
|
||||
ratio=round(int.from_bytes(self._COMP_comprate, 'little') * 0.01, 2),
|
||||
threshold=round(
|
||||
int.from_bytes(self._COMP_threshold, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
c_enabled=bool(int.from_bytes(self._COMP_c_enabled, 'little')),
|
||||
makeup=bool(int.from_bytes(self._COMP_c_auto, 'little')),
|
||||
gain_out=round(
|
||||
int.from_bytes(self._COMP_gain_out, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def gate(self) -> GateSettings:
|
||||
return GateSettings(
|
||||
threshold_in=round(
|
||||
int.from_bytes(self._GATE_dBThreshold_in, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
damping_max=round(
|
||||
int.from_bytes(self._GATE_dBDamping_max, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
bp_sidechain=round(
|
||||
int.from_bytes(self._GATE_BP_Sidechain, 'little') * 0.1, 2
|
||||
),
|
||||
attack_ms=round(int.from_bytes(self._GATE_attack_ms, 'little') * 0.1, 2),
|
||||
hold_ms=round(int.from_bytes(self._GATE_hold_ms, 'little') * 0.1, 2),
|
||||
release_ms=round(int.from_bytes(self._GATE_release_ms, 'little') * 0.1, 2),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def denoiser(self) -> DenoiserSettings:
|
||||
return DenoiserSettings(
|
||||
threshold=round(
|
||||
int.from_bytes(self._DenoiserThreshold, 'little', signed=True) * 0.01, 2
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def pitch(self) -> PitchSettings:
|
||||
return PitchSettings(
|
||||
enabled=bool(int.from_bytes(self._PitchEnabled, 'little')),
|
||||
dry_wet=round(
|
||||
int.from_bytes(self._Pitch_DryWet, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
value=round(
|
||||
int.from_bytes(self._Pitch_Value, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_lo=round(
|
||||
int.from_bytes(self._Pitch_formant_lo, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_med=round(
|
||||
int.from_bytes(self._Pitch_formant_med, 'little', signed=True) * 0.01, 2
|
||||
),
|
||||
formant_high=round(
|
||||
int.from_bytes(self._Pitch_formant_high, 'little', signed=True) * 0.01,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanRTPacketNBS1(VbanRTPacket):
|
||||
"""Represents the body of a VBAN RTPacket with ident:1"""
|
||||
|
||||
strips: tuple[VbanVMParamStrip, ...]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(
|
||||
cls,
|
||||
nbs: NBS,
|
||||
kind: KindMapClass,
|
||||
data: bytes,
|
||||
):
|
||||
return cls(
|
||||
nbs=nbs,
|
||||
_kind=kind,
|
||||
_voicemeeterType=data[28:29],
|
||||
_reserved=data[29:30],
|
||||
_buffersize=data[30:32],
|
||||
_voicemeeterVersion=data[32:36],
|
||||
_optionBits=data[36:40],
|
||||
_samplerate=data[40:44],
|
||||
strips=tuple(
|
||||
VbanVMParamStrip.from_bytes(
|
||||
data[44 + i * VMPARAMSTRIP_SIZE : 44 + (i + 1) * VMPARAMSTRIP_SIZE]
|
||||
)
|
||||
for i in range(16)
|
||||
),
|
||||
)
|
||||
126
vban_cmd/packet/ping0.py
Normal file
126
vban_cmd/packet/ping0.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from .headers import VbanPingHeader
|
||||
|
||||
# VBAN PING bitType constants
|
||||
VBANPING_TYPE_RECEPTOR = 0x00000001 # Simple receptor
|
||||
VBANPING_TYPE_TRANSMITTER = 0x00000002 # Simple Transmitter
|
||||
VBANPING_TYPE_RECEPTORSPOT = 0x00000004 # SPOT receptor
|
||||
VBANPING_TYPE_TRANSMITTERSPOT = 0x00000008 # SPOT transmitter
|
||||
VBANPING_TYPE_VIRTUALDEVICE = 0x00000010 # Virtual Device
|
||||
VBANPING_TYPE_VIRTUALMIXER = 0x00000020 # Virtual Mixer
|
||||
VBANPING_TYPE_MATRIX = 0x00000040 # MATRIX
|
||||
VBANPING_TYPE_DAW = 0x00000080 # Workstation
|
||||
VBANPING_TYPE_SERVER = 0x01000000 # VBAN SERVER
|
||||
|
||||
# VBAN PING bitfeature constants
|
||||
VBANPING_FEATURE_AUDIO = 0x00000001
|
||||
VBANPING_FEATURE_AOIP = 0x00000002
|
||||
VBANPING_FEATURE_VOIP = 0x00000004
|
||||
VBANPING_FEATURE_SERIAL = 0x00000100
|
||||
VBANPING_FEATURE_MIDI = 0x00000300
|
||||
VBANPING_FEATURE_FRAME = 0x00001000
|
||||
VBANPING_FEATURE_TXT = 0x00010000
|
||||
|
||||
|
||||
class VbanServerType(Enum):
|
||||
"""VBAN server types detected from PONG responses"""
|
||||
|
||||
UNKNOWN = 0
|
||||
VOICEMEETER = VBANPING_TYPE_VIRTUALMIXER
|
||||
MATRIX = VBANPING_TYPE_MATRIX
|
||||
|
||||
|
||||
@dataclass
|
||||
class VbanPing0Payload:
|
||||
"""Represents the VBAN PING0 payload structure as defined in the VBAN protocol documentation."""
|
||||
|
||||
def __init__(self):
|
||||
self.bit_type = VBANPING_TYPE_RECEPTOR
|
||||
self.bit_feature = VBANPING_FEATURE_TXT
|
||||
self.bit_feature_ex = 0x00000000
|
||||
self.preferred_rate = 48000
|
||||
self.min_rate = 8000
|
||||
self.max_rate = 192000
|
||||
self.color_rgb = 0x00FF0000
|
||||
self.version = b'\x01\x02\x03\x04'
|
||||
self.gps_position = b'\x00' * 8
|
||||
self.user_position = b'\x00' * 8
|
||||
self.lang_code = b'EN\x00\x00\x00\x00\x00\x00'
|
||||
self.reserved = b'\x00' * 8
|
||||
self.reserved_ex = b'\x00' * 64
|
||||
self.distant_ip = b'\x00' * 32
|
||||
self.distant_port = 0
|
||||
self.distant_reserved = 0
|
||||
self.device_name = b'VBAN-CMD-Python\x00'.ljust(64, b'\x00')
|
||||
self.manufacturer_name = b'Python-VBAN\x00'.ljust(64, b'\x00')
|
||||
self.application_name = b'vban-cmd\x00'.ljust(64, b'\x00')
|
||||
self.host_name = b'localhost\x00'.ljust(64, b'\x00')
|
||||
self.user_name = b'Python User\x00'.ljust(128, b'\x00')
|
||||
self.user_comment = b'VBAN CMD Python Client\x00'.ljust(128, b'\x00')
|
||||
|
||||
@classmethod
|
||||
def to_bytes(cls) -> bytes:
|
||||
"""Convert payload to bytes"""
|
||||
payload = cls()
|
||||
|
||||
return struct.pack(
|
||||
'<7I4s8s8s8s8s64s32s2H64s64s64s64s128s128s',
|
||||
payload.bit_type,
|
||||
payload.bit_feature,
|
||||
payload.bit_feature_ex,
|
||||
payload.preferred_rate,
|
||||
payload.min_rate,
|
||||
payload.max_rate,
|
||||
payload.color_rgb,
|
||||
payload.version,
|
||||
payload.gps_position,
|
||||
payload.user_position,
|
||||
payload.lang_code,
|
||||
payload.reserved,
|
||||
payload.reserved_ex,
|
||||
payload.distant_ip,
|
||||
payload.distant_port,
|
||||
payload.distant_reserved,
|
||||
payload.device_name,
|
||||
payload.manufacturer_name,
|
||||
payload.application_name,
|
||||
payload.host_name,
|
||||
payload.user_name,
|
||||
payload.user_comment,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_packet(cls, framecounter: int) -> bytes:
|
||||
"""Creates a complete PING packet with header and payload."""
|
||||
data = bytearray()
|
||||
data.extend(VbanPingHeader.to_bytes(framecounter))
|
||||
data.extend(cls.to_bytes())
|
||||
return bytes(data)
|
||||
|
||||
@staticmethod
|
||||
def detect_server_type(pong_data: bytes) -> VbanServerType:
|
||||
"""Detect server type from PONG response packet.
|
||||
|
||||
Args:
|
||||
pong_data: Raw bytes from PONG response packet
|
||||
|
||||
Returns:
|
||||
VbanServerType enum indicating the detected server type
|
||||
"""
|
||||
try:
|
||||
if len(pong_data) >= 32:
|
||||
frame_counter_bytes = pong_data[28:32]
|
||||
frame_counter = int.from_bytes(frame_counter_bytes, 'little')
|
||||
|
||||
if frame_counter == VbanServerType.MATRIX.value:
|
||||
return VbanServerType.MATRIX
|
||||
elif frame_counter == VbanServerType.VOICEMEETER.value:
|
||||
return VbanServerType.VOICEMEETER
|
||||
|
||||
return VbanServerType.UNKNOWN
|
||||
|
||||
except Exception:
|
||||
return VbanServerType.UNKNOWN
|
||||
138
vban_cmd/recorder.py
Normal file
138
vban_cmd/recorder.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from .error import VBANCMDError
|
||||
from .iremote import IRemote
|
||||
from .meta import action_fn
|
||||
|
||||
|
||||
class Recorder(IRemote):
|
||||
"""
|
||||
Implements the common interface
|
||||
|
||||
Defines concrete implementation for recorder
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def make(cls, remote):
|
||||
"""
|
||||
Factory function for recorder class.
|
||||
|
||||
Returns a Recorder class of a kind.
|
||||
"""
|
||||
Recorder_cls = type(
|
||||
f'Recorder{remote.kind}',
|
||||
(cls,),
|
||||
{
|
||||
**{
|
||||
param: action_fn(param)
|
||||
for param in [
|
||||
'play',
|
||||
'stop',
|
||||
'pause',
|
||||
'replay',
|
||||
'record',
|
||||
'ff',
|
||||
'rew',
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
return Recorder_cls(remote)
|
||||
|
||||
def __str__(self):
|
||||
return f'{type(self).__name__}'
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return 'recorder'
|
||||
|
||||
@property
|
||||
def samplerate(self) -> int:
|
||||
return
|
||||
|
||||
@samplerate.setter
|
||||
def samplerate(self, val: int):
|
||||
opts = (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
|
||||
if val not in opts:
|
||||
self.logger.warning(f'samplerate got: {val} but expected a value in {opts}')
|
||||
self.setter('samplerate', val)
|
||||
|
||||
@property
|
||||
def bitresolution(self) -> int:
|
||||
return
|
||||
|
||||
@bitresolution.setter
|
||||
def bitresolution(self, val: int):
|
||||
opts = (8, 16, 24, 32)
|
||||
if val not in opts:
|
||||
self.logger.warning(
|
||||
f'bitresolution got: {val} but expected a value in {opts}'
|
||||
)
|
||||
self.setter('bitresolution', val)
|
||||
|
||||
@property
|
||||
def channel(self) -> int:
|
||||
return
|
||||
|
||||
@channel.setter
|
||||
def channel(self, val: int):
|
||||
if not 1 <= val <= 8:
|
||||
self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
|
||||
self.setter('channel', val)
|
||||
|
||||
@property
|
||||
def kbps(self):
|
||||
return
|
||||
|
||||
@kbps.setter
|
||||
def kbps(self, val: int):
|
||||
opts = (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
|
||||
if val not in opts:
|
||||
self.logger.warning(f'kbps got: {val} but expected a value in {opts}')
|
||||
self.setter('kbps', val)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
return
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter('gain', val)
|
||||
|
||||
def load(self, file: os.PathLike):
|
||||
try:
|
||||
# Convert to string, use forward slashes, and wrap in quotes for spaces
|
||||
file_path = f'"{os.fspath(file).replace(chr(92), "/")}"'
|
||||
self.setter('load', file_path)
|
||||
except UnicodeError:
|
||||
raise VBANCMDError('File full directory must be a raw string')
|
||||
|
||||
def goto(self, time_str):
|
||||
def get_sec():
|
||||
"""Get seconds from time string"""
|
||||
h, m, s = time_str.split(':')
|
||||
return int(h) * 3600 + int(m) * 60 + int(s)
|
||||
|
||||
time_str = str(time_str) # coerce the type
|
||||
if (
|
||||
re.match(
|
||||
r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$',
|
||||
time_str,
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self.setter('goto', get_sec())
|
||||
else:
|
||||
self.logger.warning(
|
||||
"goto expects a string that matches the format 'hh:mm:ss'"
|
||||
)
|
||||
|
||||
def filetype(self, val: str):
|
||||
opts = {'wav': 1, 'aiff': 2, 'bwf': 3, 'mp3': 100}
|
||||
try:
|
||||
self.setter('filetype', opts[val.lower()])
|
||||
except KeyError:
|
||||
self.logger.warning(
|
||||
f'filetype got: {val} but expected a value in {list(opts.keys())}'
|
||||
)
|
||||
@@ -1,10 +1,17 @@
|
||||
import abc
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from typing import Union
|
||||
|
||||
from . import kinds
|
||||
from .enums import NBS
|
||||
from .iremote import IRemote
|
||||
from .kinds import kinds_all
|
||||
from .meta import channel_bool_prop, channel_label_prop, strip_output_prop
|
||||
from .meta import (
|
||||
channel_bool_prop,
|
||||
channel_label_prop,
|
||||
send_prop,
|
||||
strip_output_prop,
|
||||
xy_prop,
|
||||
)
|
||||
|
||||
|
||||
class Strip(IRemote):
|
||||
@@ -14,13 +21,13 @@ class Strip(IRemote):
|
||||
Defines concrete implementation for strip
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
@@ -28,212 +35,268 @@ class Strip(IRemote):
|
||||
|
||||
@limit.setter
|
||||
def limit(self, val: int):
|
||||
self.setter("limit", val)
|
||||
self.setter('limit', val)
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
val = self.getter("gain")
|
||||
val = self.getter('gain')
|
||||
if val is None:
|
||||
val = self.gainlayer[0].gain
|
||||
val = max(layer.gain for layer in self.gainlayer)
|
||||
return round(val, 1)
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter("gain", val)
|
||||
self.setter('gain', val)
|
||||
|
||||
def fadeto(self, target: float, time_: int):
|
||||
self.setter("FadeTo", f"({target}, {time_})")
|
||||
self.setter('FadeTo', f'({target}, {time_})')
|
||||
time.sleep(self._remote.DELAY)
|
||||
|
||||
def fadeby(self, change: float, time_: int):
|
||||
self.setter("FadeBy", f"({change}, {time_})")
|
||||
self.setter('FadeBy', f'({change}, {time_})')
|
||||
time.sleep(self._remote.DELAY)
|
||||
|
||||
|
||||
class PhysicalStrip(Strip):
|
||||
@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(
|
||||
f"PhysicalStrip{remote.kind}",
|
||||
(cls,),
|
||||
f'PhysicalStrip{remote.kind}',
|
||||
(cls, EFFECTS_cls),
|
||||
{
|
||||
"comp": StripComp(remote, index),
|
||||
"gate": StripGate(remote, index),
|
||||
"denoiser": StripDenoiser(remote, index),
|
||||
"eq": StripEQ(remote, index),
|
||||
'comp': StripComp(remote, index),
|
||||
'gate': StripGate(remote, index),
|
||||
'denoiser': StripDenoiser(remote, index),
|
||||
'eq': StripEQ.make(remote, index),
|
||||
},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
return
|
||||
def audibility(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].audibility.knob
|
||||
|
||||
@property
|
||||
def sr(self):
|
||||
return
|
||||
@audibility.setter
|
||||
def audibility(self, val: float):
|
||||
self.setter('audibility', val)
|
||||
|
||||
|
||||
class StripComp(IRemote):
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}].comp"
|
||||
return f'strip[{self.index}].comp'
|
||||
|
||||
@property
|
||||
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
|
||||
def knob(self, val: float):
|
||||
self.setter("", val)
|
||||
self.setter('', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def gainin(self, val: float):
|
||||
self.setter("GainIn", val)
|
||||
self.setter('GainIn', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def ratio(self, val: float):
|
||||
self.setter("Ratio", val)
|
||||
self.setter('Ratio', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def threshold(self, val: float):
|
||||
self.setter("Threshold", val)
|
||||
self.setter('Threshold', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def attack(self, val: float):
|
||||
self.setter("Attack", val)
|
||||
self.setter('Attack', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def release(self, val: float):
|
||||
self.setter("Release", val)
|
||||
self.setter('Release', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def knee(self, val: float):
|
||||
self.setter("Knee", val)
|
||||
self.setter('Knee', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def gainout(self, val: float):
|
||||
self.setter("GainOut", val)
|
||||
self.setter('GainOut', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def makeup(self, val: bool):
|
||||
self.setter("makeup", 1 if val else 0)
|
||||
self.setter('makeup', 1 if val else 0)
|
||||
|
||||
|
||||
class StripGate(IRemote):
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}].gate"
|
||||
return f'strip[{self.index}].gate'
|
||||
|
||||
@property
|
||||
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
|
||||
def knob(self, val: float):
|
||||
self.setter("", val)
|
||||
self.setter('', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def threshold(self, val: float):
|
||||
self.setter("Threshold", val)
|
||||
self.setter('Threshold', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def damping(self, val: float):
|
||||
self.setter("Damping", val)
|
||||
self.setter('Damping', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def bpsidechain(self, val: int):
|
||||
self.setter("BPSidechain", val)
|
||||
self.setter('BPSidechain', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def attack(self, val: float):
|
||||
self.setter("Attack", val)
|
||||
self.setter('Attack', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def hold(self, val: float):
|
||||
self.setter("Hold", val)
|
||||
self.setter('Hold', val)
|
||||
|
||||
@property
|
||||
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
|
||||
def release(self, val: float):
|
||||
self.setter("Release", val)
|
||||
self.setter('Release', val)
|
||||
|
||||
|
||||
class StripDenoiser(IRemote):
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}].denoiser"
|
||||
return f'strip[{self.index}].denoiser'
|
||||
|
||||
@property
|
||||
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
|
||||
def knob(self, val: float):
|
||||
self.setter("", val)
|
||||
self.setter('', val)
|
||||
|
||||
|
||||
class StripEQ(IRemote):
|
||||
@classmethod
|
||||
def make(cls, remote, i):
|
||||
"""
|
||||
Factory method for Strip EQ.
|
||||
|
||||
Returns a StripEQ class.
|
||||
"""
|
||||
STRIPEQ_cls = type(
|
||||
'StripEQ',
|
||||
(cls,),
|
||||
{
|
||||
'channel': tuple(
|
||||
StripEQCh.make(remote, i, j)
|
||||
for j in range(remote.kind.strip_channels)
|
||||
)
|
||||
},
|
||||
)
|
||||
return STRIPEQ_cls(remote, i)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}].eq"
|
||||
return f'strip[{self.index}].eq'
|
||||
|
||||
@property
|
||||
def on(self):
|
||||
@@ -241,7 +304,7 @@ class StripEQ(IRemote):
|
||||
|
||||
@on.setter
|
||||
def on(self, val: bool):
|
||||
self.setter("on", 1 if val else 0)
|
||||
self.setter('on', 1 if val else 0)
|
||||
|
||||
@property
|
||||
def ab(self):
|
||||
@@ -249,30 +312,214 @@ class StripEQ(IRemote):
|
||||
|
||||
@ab.setter
|
||||
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):
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self.index}"
|
||||
@classmethod
|
||||
def make(cls, remote, i, is_phys):
|
||||
"""
|
||||
Factory method for VirtualStrip.
|
||||
|
||||
mc = channel_bool_prop("mc")
|
||||
Returns a VirtualStrip class.
|
||||
"""
|
||||
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
|
||||
return type(
|
||||
'VirtualStrip',
|
||||
(cls, EFFECTS_cls),
|
||||
{},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f'{type(self).__name__}{self.index}'
|
||||
|
||||
mc = channel_bool_prop('mc')
|
||||
|
||||
mono = mc
|
||||
|
||||
@property
|
||||
def k(self) -> int:
|
||||
return
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0
|
||||
return self.public_packets[NBS.one].strips[self.index].karaoke
|
||||
|
||||
@k.setter
|
||||
def k(self, val: int):
|
||||
self.setter("karaoke", val)
|
||||
self.setter('karaoke', val)
|
||||
|
||||
@property
|
||||
def bass(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.bass
|
||||
|
||||
@bass.setter
|
||||
def bass(self, val: float):
|
||||
self.setter('EQGain1', val)
|
||||
|
||||
@property
|
||||
def mid(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.mid
|
||||
|
||||
@mid.setter
|
||||
def mid(self, val: float):
|
||||
self.setter('EQGain2', val)
|
||||
|
||||
med = mid
|
||||
|
||||
@property
|
||||
def treble(self) -> float:
|
||||
if self.public_packets[NBS.one] is None:
|
||||
return 0.0
|
||||
return self.public_packets[NBS.one].strips[self.index].eqgains.treble
|
||||
|
||||
@treble.setter
|
||||
def treble(self, val: float):
|
||||
self.setter('EQGain3', val)
|
||||
|
||||
high = treble
|
||||
|
||||
def appgain(self, name: str, gain: float):
|
||||
self.setter("AppGain", f'("{name}", {gain})')
|
||||
self.setter('AppGain', f'("{name}", {gain})')
|
||||
|
||||
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):
|
||||
@@ -293,26 +540,15 @@ class StripLevel(IRemote):
|
||||
def getter(self):
|
||||
"""Returns a tuple of level values for the channel."""
|
||||
|
||||
def fget(i):
|
||||
return round((((1 << 16) - 1) - i) * -0.01, 1)
|
||||
|
||||
if not self._remote.stopped() and self._remote.event.ldirty:
|
||||
return tuple(
|
||||
fget(i)
|
||||
for i in self._remote.cache["strip_level"][
|
||||
self.range[0] : self.range[-1]
|
||||
]
|
||||
)
|
||||
return tuple(
|
||||
fget(i)
|
||||
for i in self._remote._get_levels(self.public_packet)[0][
|
||||
self.range[0] : self.range[-1]
|
||||
]
|
||||
)
|
||||
return self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
|
||||
return self.public_packets[NBS.zero].levels.strip[
|
||||
self.range[0] : self.range[-1]
|
||||
]
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def prefader(self) -> tuple:
|
||||
@@ -345,31 +581,28 @@ class GainLayer(IRemote):
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"Strip[{self.index}]"
|
||||
return f'strip[{self.index}]'
|
||||
|
||||
@property
|
||||
def gain(self) -> float:
|
||||
def fget():
|
||||
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index]
|
||||
if 0 <= val <= 1200:
|
||||
return val * 0.01
|
||||
return (((1 << 16) - 1) - val) * -0.01
|
||||
|
||||
val = self.getter(f"GainLayer[{self._i}]")
|
||||
return round(val if val else fget(), 1)
|
||||
val = self.getter(f'GainLayer[{self._i}]')
|
||||
if val:
|
||||
return round(val, 2)
|
||||
else:
|
||||
return self.public_packets[NBS.zero].gainlayers[self._i][self.index]
|
||||
|
||||
@gain.setter
|
||||
def gain(self, val: float):
|
||||
self.setter(f"GainLayer[{self._i}]", val)
|
||||
self.setter(f'GainLayer[{self._i}]', val)
|
||||
|
||||
|
||||
def _make_gainlayer_mixin(remote, index):
|
||||
"""Creates a GainLayer mixin"""
|
||||
return type(
|
||||
f"GainlayerMixin",
|
||||
'GainlayerMixin',
|
||||
(),
|
||||
{
|
||||
"gainlayer": tuple(
|
||||
'gainlayer': tuple(
|
||||
GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
|
||||
)
|
||||
},
|
||||
@@ -379,24 +612,78 @@ def _make_gainlayer_mixin(remote, index):
|
||||
def _make_channelout_mixin(kind):
|
||||
"""Creates a channel out property mixin"""
|
||||
return type(
|
||||
f"ChannelOutMixin{kind}",
|
||||
f'ChannelOutMixin{kind}',
|
||||
(),
|
||||
{
|
||||
**{
|
||||
f"A{i}": strip_output_prop(f"A{i}") for i in range(1, kind.phys_out + 1)
|
||||
f'A{i}': strip_output_prop(f'A{i}') for i in range(1, kind.phys_out + 1)
|
||||
},
|
||||
**{
|
||||
f"B{i}": strip_output_prop(f"B{i}") for i in range(1, kind.virt_out + 1)
|
||||
f'B{i}': strip_output_prop(f'B{i}') for i in range(1, kind.virt_out + 1)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_make_channelout_mixins = {
|
||||
kind.name: _make_channelout_mixin(kind) for kind in kinds_all
|
||||
kind.name: _make_channelout_mixin(kind) for kind in kinds.all
|
||||
}
|
||||
|
||||
|
||||
def _make_effects_mixin(kind, is_phys):
|
||||
"""creates an effects mixin for a kind"""
|
||||
|
||||
def _make_xy_cls():
|
||||
pan = {param: xy_prop(param) for param in ['pan_x', 'pan_y']}
|
||||
color = {param: xy_prop(param) for param in ['color_x', 'color_y']}
|
||||
fx = {param: xy_prop(param) for param in ['fx_x', 'fx_y']}
|
||||
if is_phys:
|
||||
return type(
|
||||
'XYPhys',
|
||||
(),
|
||||
{
|
||||
**pan,
|
||||
**color,
|
||||
**fx,
|
||||
},
|
||||
)
|
||||
return type(
|
||||
'XYVirt',
|
||||
(),
|
||||
{**pan},
|
||||
)
|
||||
|
||||
def _make_sends_cls():
|
||||
if is_phys:
|
||||
return type(
|
||||
'FX',
|
||||
(),
|
||||
{
|
||||
**{
|
||||
param: send_prop(param)
|
||||
for param in ['reverb', 'delay', 'fx1', 'fx2']
|
||||
},
|
||||
# **{
|
||||
# f'post{param}': bool_prop(f'post{param}')
|
||||
# for param in ['reverb', 'delay', 'fx1', 'fx2']
|
||||
# },
|
||||
},
|
||||
)
|
||||
return type('FX', (), {})
|
||||
|
||||
if kind.name == 'basic':
|
||||
steps = (_make_xy_cls,)
|
||||
elif kind.name == 'banana':
|
||||
steps = (_make_xy_cls,)
|
||||
elif kind.name == 'potato':
|
||||
steps = (_make_xy_cls, _make_sends_cls)
|
||||
return type(f'Effects{kind}', tuple(step() for step in steps), {})
|
||||
|
||||
|
||||
def _make_effects_mixins(is_phys):
|
||||
return {kind.name: _make_effects_mixin(kind, is_phys) for kind in kinds.all}
|
||||
|
||||
|
||||
def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]:
|
||||
"""
|
||||
Factory method for strips
|
||||
@@ -405,17 +692,21 @@ def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip
|
||||
|
||||
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]
|
||||
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
|
||||
|
||||
return type(
|
||||
f"{STRIP_cls.__name__}{remote.kind}",
|
||||
f'{STRIP_cls.__name__}{remote.kind}',
|
||||
(STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls),
|
||||
{
|
||||
"levels": StripLevel(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ["mono", "solo", "mute"]},
|
||||
"label": channel_label_prop(),
|
||||
'levels': StripLevel(remote, i),
|
||||
**{param: channel_bool_prop(param) for param in ['mono', 'solo', 'mute']},
|
||||
'label': channel_label_prop(),
|
||||
},
|
||||
)(remote, i)
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ class Subject:
|
||||
"""run callbacks on update"""
|
||||
|
||||
for o in self._observers:
|
||||
if hasattr(o, "on_update"):
|
||||
if hasattr(o, 'on_update'):
|
||||
o.on_update(event)
|
||||
else:
|
||||
if o.__name__ == f"on_{event}":
|
||||
if o.__name__ == f'on_{event}':
|
||||
o()
|
||||
|
||||
def add(self, observer):
|
||||
@@ -34,15 +34,15 @@ class Subject:
|
||||
for o in iterator:
|
||||
if o not in self._observers:
|
||||
self._observers.append(o)
|
||||
self.logger.info(f"{o} added to event observers")
|
||||
self.logger.info(f'{o} added to event observers')
|
||||
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:
|
||||
if observer not in self._observers:
|
||||
self._observers.append(observer)
|
||||
self.logger.info(f"{observer} added to event observers")
|
||||
self.logger.info(f'{observer} added to event observers')
|
||||
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
|
||||
|
||||
@@ -54,15 +54,15 @@ class Subject:
|
||||
for o in iterator:
|
||||
try:
|
||||
self._observers.remove(o)
|
||||
self.logger.info(f"{o} removed from event observers")
|
||||
self.logger.info(f'{o} removed from event observers')
|
||||
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:
|
||||
try:
|
||||
self._observers.remove(observer)
|
||||
self.logger.info(f"{observer} removed from event observers")
|
||||
self.logger.info(f'{observer} removed from event observers')
|
||||
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
|
||||
|
||||
|
||||
133
vban_cmd/util.py
133
vban_cmd/util.py
@@ -1,15 +1,84 @@
|
||||
from enum import IntEnum
|
||||
import socket
|
||||
import time
|
||||
from typing import Iterator
|
||||
|
||||
from .error import VBANCMDConnectionError
|
||||
|
||||
|
||||
def ratelimit(func):
|
||||
"""ratelimit decorator for {VbanCmd}.sendtext, to prevent flooding the network with script requests."""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self.script_ratelimit > 0:
|
||||
now = time.time()
|
||||
elapsed = now - self._last_script_request_time
|
||||
if elapsed < self.script_ratelimit:
|
||||
time.sleep(self.script_ratelimit - elapsed)
|
||||
self._last_script_request_time = time.time()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def pong_timeout(func):
|
||||
"""pong_timeout decorator for {VbanCmd}._handle_pong, to handle timeout logic and socket management."""
|
||||
|
||||
def wrapper(self, timeout: float = None):
|
||||
if timeout is None:
|
||||
timeout = min(self.timeout, 3.0)
|
||||
|
||||
original_timeout = self.sock.gettimeout()
|
||||
self.sock.settimeout(0.5)
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
response_count = 0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response_count += 1
|
||||
|
||||
if func(self):
|
||||
return
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
self.logger.debug(
|
||||
f'PING timeout after {timeout}s, received {response_count} non-PONG packets'
|
||||
)
|
||||
raise VBANCMDConnectionError(
|
||||
f'PING timeout: No response from {self.host}:{self.port} after {timeout}s'
|
||||
)
|
||||
|
||||
finally:
|
||||
self.sock.settimeout(original_timeout)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_bool(func, param):
|
||||
"""Check cache for a bool prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
cmd = f"{self.identifier}.{param}"
|
||||
if cmd in self._remote.cache:
|
||||
return self._remote.cache.pop(cmd) == 1
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param)) == 1
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_int(func, param):
|
||||
"""Check cache for an int prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param))
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
@@ -22,9 +91,22 @@ def cache_string(func, param):
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
cmd = f"{self.identifier}.{param}"
|
||||
if cmd in self._remote.cache:
|
||||
return self._remote.cache.pop(cmd)
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return self._remote.cache.pop(self._cmd(param)).strip('"')
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def cache_float(func, param):
|
||||
"""Check cache for a float prop"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
self, *rem = args
|
||||
if self._cmd(param) in self._remote.cache:
|
||||
return round(self._remote.cache.pop(self._cmd(param)), 2)
|
||||
if self._remote.sync:
|
||||
self._remote.clear_dirty()
|
||||
return func(*args, **kwargs)
|
||||
@@ -38,39 +120,15 @@ def depth(d):
|
||||
return 0
|
||||
|
||||
|
||||
def script(func):
|
||||
"""Convert dictionary to script"""
|
||||
|
||||
def wrapper(*args):
|
||||
remote, script = args
|
||||
if isinstance(script, dict):
|
||||
params = ""
|
||||
for key, val in script.items():
|
||||
obj, m2, *rem = key.split("-")
|
||||
index = int(m2) if m2.isnumeric() else int(*rem)
|
||||
params += ";".join(
|
||||
f"{obj}{f'.{m2}stream' if not m2.isnumeric() else ''}[{index}].{k}={int(v) if isinstance(v, bool) else v}"
|
||||
for k, v in val.items()
|
||||
)
|
||||
params += ";"
|
||||
script = params
|
||||
return func(remote, script)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
|
||||
"""
|
||||
Generator function, accepts two tuples.
|
||||
Generator function, accepts two tuples of dB values.
|
||||
|
||||
Evaluates equality of each member in both tuples.
|
||||
Returns True when levels are equal (no change), False when different.
|
||||
"""
|
||||
|
||||
for a, b in zip(t0, t1):
|
||||
if ((1 << 16) - 1) - b <= 7200:
|
||||
yield a == b
|
||||
else:
|
||||
yield True
|
||||
yield a == b
|
||||
|
||||
|
||||
def deep_merge(dict1, dict2):
|
||||
@@ -87,4 +145,9 @@ def deep_merge(dict1, dict2):
|
||||
yield k, dict2[k]
|
||||
|
||||
|
||||
Socket = IntEnum("Socket", "register request response", start=0)
|
||||
def bump_framecounter(framecounter: int) -> int:
|
||||
"""Increment framecounter with rollover at 0xFFFFFFFF."""
|
||||
if framecounter > 0xFFFFFFFF:
|
||||
return 0
|
||||
else:
|
||||
return framecounter + 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from abc import abstractmethod
|
||||
import abc
|
||||
|
||||
from . import kinds
|
||||
from .iremote import IRemote
|
||||
from .kinds import kinds_all
|
||||
|
||||
|
||||
class VbanStream(IRemote):
|
||||
@@ -11,13 +11,13 @@ class VbanStream(IRemote):
|
||||
Defines concrete implementation for vban stream
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
return f"vban.{self.direction}stream[{self.index}]"
|
||||
return f'vban.{self.direction}stream[{self.index}]'
|
||||
|
||||
@property
|
||||
def on(self) -> bool:
|
||||
@@ -25,7 +25,7 @@ class VbanStream(IRemote):
|
||||
|
||||
@on.setter
|
||||
def on(self, val: bool):
|
||||
self.setter("on", 1 if val else 0)
|
||||
self.setter('on', 1 if val else 0)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -33,7 +33,7 @@ class VbanStream(IRemote):
|
||||
|
||||
@name.setter
|
||||
def name(self, val: str):
|
||||
self.setter("name", val)
|
||||
self.setter('name', val)
|
||||
|
||||
@property
|
||||
def ip(self) -> str:
|
||||
@@ -41,7 +41,7 @@ class VbanStream(IRemote):
|
||||
|
||||
@ip.setter
|
||||
def ip(self, val: str):
|
||||
self.setter("ip", val)
|
||||
self.setter('ip', val)
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
@@ -51,9 +51,9 @@ class VbanStream(IRemote):
|
||||
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"
|
||||
f'port got: {val} but expected a value from 1024 to 65535'
|
||||
)
|
||||
self.setter("port", val)
|
||||
self.setter('port', val)
|
||||
|
||||
@property
|
||||
def sr(self) -> int:
|
||||
@@ -63,8 +63,8 @@ class VbanStream(IRemote):
|
||||
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)
|
||||
self.logger.warning(f'sr got: {val} but expected a value in {opts}')
|
||||
self.setter('sr', val)
|
||||
|
||||
@property
|
||||
def channel(self) -> int:
|
||||
@@ -73,8 +73,8 @@ class VbanStream(IRemote):
|
||||
@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)
|
||||
self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
|
||||
self.setter('channel', val)
|
||||
|
||||
@property
|
||||
def bit(self) -> int:
|
||||
@@ -83,8 +83,8 @@ class VbanStream(IRemote):
|
||||
@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)
|
||||
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:
|
||||
@@ -93,8 +93,8 @@ class VbanStream(IRemote):
|
||||
@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)
|
||||
self.logger.warning(f'quality got: {val} but expected a value from 0 to 4')
|
||||
self.setter('quality', val)
|
||||
|
||||
@property
|
||||
def route(self) -> int:
|
||||
@@ -103,8 +103,8 @@ class VbanStream(IRemote):
|
||||
@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)
|
||||
self.logger.warning(f'route got: {val} but expected a value from 0 to 8')
|
||||
self.setter('route', val)
|
||||
|
||||
|
||||
class VbanInstream(VbanStream):
|
||||
@@ -115,11 +115,11 @@ class VbanInstream(VbanStream):
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self._remote.kind}{self.index}"
|
||||
return f'{type(self).__name__}{self._remote.kind}{self.index}'
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
return "in"
|
||||
return 'in'
|
||||
|
||||
@property
|
||||
def sr(self) -> int:
|
||||
@@ -154,11 +154,11 @@ class VbanOutstream(VbanStream):
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return f"{type(self).__name__}{self._remote.kind}{self.index}"
|
||||
return f'{type(self).__name__}{self._remote.kind}{self.index}'
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
return "out"
|
||||
return 'out'
|
||||
|
||||
|
||||
class VbanAudioOutstream(VbanOutstream):
|
||||
@@ -172,37 +172,29 @@ class VbanMidiOutstream(VbanOutstream):
|
||||
def _make_stream_pair(remote, kind):
|
||||
num_instream, num_outstream, num_midi, num_text = kind.vban
|
||||
|
||||
def _generate_streams(i, dir):
|
||||
"""generator function for creating instream/outstream tuples"""
|
||||
if dir == "in":
|
||||
if i < num_instream:
|
||||
yield VbanAudioInstream
|
||||
elif i < num_instream + num_midi:
|
||||
yield VbanMidiInstream
|
||||
else:
|
||||
yield VbanTextInstream
|
||||
else:
|
||||
if i < num_outstream:
|
||||
yield VbanAudioOutstream
|
||||
else:
|
||||
yield VbanMidiOutstream
|
||||
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(
|
||||
cls(remote, i)
|
||||
for i in range(num_instream + num_midi + num_text)
|
||||
for cls in _generate_streams(i, "in")
|
||||
),
|
||||
tuple(
|
||||
cls(remote, i)
|
||||
for i in range(num_outstream + num_midi)
|
||||
for cls in _generate_streams(i, "out")
|
||||
),
|
||||
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}
|
||||
return {kind.name: _make_stream_pair(remote, kind) for kind in kinds.all}
|
||||
|
||||
|
||||
class Vban:
|
||||
@@ -220,7 +212,7 @@ class Vban:
|
||||
"""if VBAN disabled there can be no communication with it"""
|
||||
|
||||
def disable(self):
|
||||
self.remote._set_rt("vban.Enable", 0)
|
||||
self.remote._set_rt('vban.Enable', 0)
|
||||
|
||||
|
||||
def vban_factory(remote) -> Vban:
|
||||
@@ -230,7 +222,7 @@ def vban_factory(remote) -> Vban:
|
||||
Returns a class that represents the VBAN module.
|
||||
"""
|
||||
VBAN_cls = Vban
|
||||
return type(f"{VBAN_cls.__name__}", (VBAN_cls,), {})(remote)
|
||||
return type(f'{VBAN_cls.__name__}', (VBAN_cls,), {})(remote)
|
||||
|
||||
|
||||
def request_vban_obj(remote) -> Vban:
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import abc
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from typing import Iterable, Union
|
||||
from typing import Mapping, Union
|
||||
|
||||
from .error import VBANCMDError
|
||||
from .enums import NBS
|
||||
from .error import VBANCMDConnectionError, VBANCMDError
|
||||
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 .util import Socket, deep_merge, script
|
||||
from .util import bump_framecounter, deep_merge, pong_timeout, ratelimit
|
||||
from .worker import Producer, Subscriber, Updater
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VbanCmd(metaclass=ABCMeta):
|
||||
"""Base class responsible for communicating with the VBAN RT Packet Service"""
|
||||
class VbanCmd(abc.ABC):
|
||||
"""Abstract Base Class for Voicemeeter VBAN Command Interfaces"""
|
||||
|
||||
DELAY = 0.001
|
||||
# fmt: off
|
||||
@@ -31,64 +37,68 @@ class VbanCmd(metaclass=ABCMeta):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self.event = Event({k: kwargs.pop(k) for k in ("pdirty", "ldirty")})
|
||||
if not kwargs["ip"]:
|
||||
self.event = Event({k: kwargs.pop(k) for k in ('pdirty', 'ldirty')})
|
||||
if not kwargs['host']:
|
||||
kwargs |= self._conn_from_toml()
|
||||
for attr, val in kwargs.items():
|
||||
setattr(self, attr, val)
|
||||
|
||||
self.packet_request = RequestHeader(
|
||||
name=self.streamname,
|
||||
bps_index=self.BPS_OPTS.index(self.bps),
|
||||
channel=self.channel,
|
||||
)
|
||||
self.socks = tuple(
|
||||
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in Socket
|
||||
)
|
||||
self._framecounter = 0
|
||||
self._framecounter_lock = threading.Lock()
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.subject = self.observer = Subject()
|
||||
self.cache = {}
|
||||
self._pdirty = False
|
||||
self._ldirty = False
|
||||
self._script = str()
|
||||
self.stop_event = None
|
||||
self.producer = None
|
||||
self._last_script_request_time = 0
|
||||
|
||||
@abstractmethod
|
||||
@abc.abstractmethod
|
||||
def __str__(self):
|
||||
"""Ensure subclasses override str magic method"""
|
||||
pass
|
||||
|
||||
def _conn_from_toml(self) -> dict:
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
import tomli as tomllib # type: ignore[import]
|
||||
|
||||
def get_filepath():
|
||||
filepaths = [
|
||||
Path.cwd() / "vban.toml",
|
||||
Path.cwd() / "configs" / "vban.toml",
|
||||
Path.home() / ".config" / "vban-cmd" / "vban.toml",
|
||||
Path.home() / "Documents" / "Voicemeeter" / "configs" / "vban.toml",
|
||||
]
|
||||
for filepath in filepaths:
|
||||
if filepath.exists():
|
||||
return filepath
|
||||
for pn in (
|
||||
Path.cwd() / 'vban.toml',
|
||||
Path.cwd() / 'configs' / 'vban.toml',
|
||||
Path.home() / '.config' / 'vban-cmd' / 'vban.toml',
|
||||
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / 'vban.toml',
|
||||
):
|
||||
if pn.exists():
|
||||
return pn
|
||||
|
||||
if filepath := get_filepath():
|
||||
with open(filepath, "rb") as f:
|
||||
conn = tomllib.load(f)
|
||||
assert (
|
||||
"connection" in conn and "ip" in conn["connection"]
|
||||
), "expected [connection][ip] in vban config"
|
||||
return conn["connection"]
|
||||
raise VBANCMDError("no ip provided and no vban.toml located.")
|
||||
if not (filepath := get_filepath()):
|
||||
raise VBANCMDError('no ip provided and no vban.toml located.')
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
return tomllib.load(f)['connection']
|
||||
except tomllib.TomlDecodeError as e:
|
||||
raise VBANCMDError(f'Error decoding {filepath}: {e}') from e
|
||||
|
||||
def __enter__(self):
|
||||
self.login()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
self.logout()
|
||||
|
||||
def login(self) -> None:
|
||||
"""Starts the subscriber and updater threads (unless in outbound mode)"""
|
||||
if not self.outbound:
|
||||
"""Sends a PING packet to the VBAN server to verify connectivity and detect server type.
|
||||
If the server is detected as Matrix, RT listeners will be disabled for compatibility.
|
||||
"""
|
||||
self._ping()
|
||||
self._handle_pong()
|
||||
|
||||
if not self.disable_rt_listeners:
|
||||
self.event.info()
|
||||
|
||||
self.stop_event = threading.Event()
|
||||
@@ -103,47 +113,138 @@ class VbanCmd(metaclass=ABCMeta):
|
||||
self.producer.start()
|
||||
|
||||
self.logger.info(
|
||||
"Successfully logged into VBANCMD {kind} with ip='{ip}', port={port}, streamname='{streamname}'".format(
|
||||
"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_set()
|
||||
return self.stop_event is None or self.stop_event.is_set()
|
||||
|
||||
def _get_next_framecounter(self) -> int:
|
||||
"""Thread-safe method to get and increment framecounter."""
|
||||
with self._framecounter_lock:
|
||||
current = self._framecounter
|
||||
self._framecounter = bump_framecounter(self._framecounter)
|
||||
return current
|
||||
|
||||
def _ping(self):
|
||||
"""Initiates the PING/PONG handshake with the VBAN server."""
|
||||
try:
|
||||
self.sock.sendto(
|
||||
VbanPing0Payload.create_packet(self._get_next_framecounter()),
|
||||
(socket.gethostbyname(self.host), self.port),
|
||||
)
|
||||
self.logger.debug(f'PING sent to {self.host}:{self.port}')
|
||||
|
||||
except socket.gaierror as e:
|
||||
raise VBANCMDConnectionError(
|
||||
f'Unable to resolve hostname {self.host}'
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise VBANCMDConnectionError(f'PING failed: {e}') from e
|
||||
|
||||
@pong_timeout
|
||||
def _handle_pong(self) -> bool:
|
||||
"""Handles incoming packets during the PING/PONG handshake, looking for a valid PONG response to confirm connectivity and detect server type.
|
||||
|
||||
Returns True if a valid PONG is received, False otherwise."""
|
||||
data, addr = self.sock.recvfrom(2048)
|
||||
|
||||
if VbanPongHeader.is_pong_response(data):
|
||||
self.logger.debug(f'PONG received from {addr}, connectivity confirmed')
|
||||
|
||||
server_type = VbanPing0Payload.detect_server_type(data)
|
||||
self._handle_server_type(server_type)
|
||||
|
||||
return True
|
||||
else:
|
||||
if len(data) >= 8:
|
||||
if data[:4] == b'VBAN':
|
||||
protocol = data[4] & 0xE0
|
||||
nbc = data[6]
|
||||
self.logger.debug(
|
||||
f'Non-PONG VBAN packet: protocol=0x{protocol:02x}, nbc=0x{nbc:02x}'
|
||||
)
|
||||
else:
|
||||
self.logger.debug('Non-VBAN packet received')
|
||||
|
||||
return False
|
||||
|
||||
def _handle_server_type(self, server_type: VbanServerType) -> None:
|
||||
"""Handle the detected server type by adjusting settings accordingly."""
|
||||
match server_type:
|
||||
case VbanServerType.VOICEMEETER:
|
||||
self.logger.debug(
|
||||
'Detected Voicemeeter VBAN server - RT listeners supported'
|
||||
)
|
||||
case VbanServerType.MATRIX:
|
||||
self.logger.info(
|
||||
'Detected Matrix VBAN server - disabling RT listeners for compatibility'
|
||||
)
|
||||
self.disable_rt_listeners = True
|
||||
case _:
|
||||
self.logger.debug(
|
||||
f'Unknown server type ({server_type}) - using default settings'
|
||||
)
|
||||
|
||||
def _send_request(self, payload: str) -> None:
|
||||
"""Sends a request packet over the network and bumps the framecounter."""
|
||||
self.sock.sendto(
|
||||
VbanRTRequestHeader.encode_with_payload(
|
||||
name=self.streamname,
|
||||
bps_index=self.BPS_OPTS.index(self.bps),
|
||||
channel=self.channel,
|
||||
framecounter=self._get_next_framecounter(),
|
||||
payload=payload,
|
||||
),
|
||||
(socket.gethostbyname(self.host), self.port),
|
||||
)
|
||||
|
||||
def _set_rt(self, cmd: str, val: Union[str, float]):
|
||||
"""Sends a string request command over a network."""
|
||||
self.socks[Socket.request].sendto(
|
||||
self.packet_request.header + f"{cmd}={val};".encode(),
|
||||
(socket.gethostbyname(self.ip), self.port),
|
||||
)
|
||||
self.packet_request.framecounter = (
|
||||
int.from_bytes(self.packet_request.framecounter, "little") + 1
|
||||
).to_bytes(4, "little")
|
||||
self._send_request(f'{cmd}={val};')
|
||||
self.cache[cmd] = val
|
||||
|
||||
@script
|
||||
def sendtext(self, script):
|
||||
@ratelimit
|
||||
def sendtext(self, script) -> str | None:
|
||||
"""Sends a multiple parameter string over a network."""
|
||||
self.socks[Socket.request].sendto(
|
||||
self.packet_request.header + script.encode(),
|
||||
(socket.gethostbyname(self.ip), self.port),
|
||||
)
|
||||
self.packet_request.framecounter = (
|
||||
int.from_bytes(self.packet_request.framecounter, "little") + 1
|
||||
).to_bytes(4, "little")
|
||||
self.logger.debug(f"sendtext: {script}")
|
||||
time.sleep(self.DELAY)
|
||||
self._send_request(script)
|
||||
self.logger.debug(f'sendtext: {script}')
|
||||
|
||||
if self.disable_rt_listeners and script.endswith(('?', '?;')):
|
||||
try:
|
||||
data, _ = self.sock.recvfrom(2048)
|
||||
return VbanMatrixResponseHeader.extract_payload(data)
|
||||
except ValueError as e:
|
||||
self.logger.warning(f'Error extracting matrix response: {e}')
|
||||
except TimeoutError as e:
|
||||
self.logger.exception(f'Timeout waiting for matrix response: {e}')
|
||||
raise VBANCMDConnectionError(
|
||||
f'Timeout waiting for response from {self.host}:{self.port}'
|
||||
) from e
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
"""Returns the type of Voicemeeter installation."""
|
||||
return self.public_packet.voicemeetertype
|
||||
return self.public_packets[NBS.zero].voicemeetertype
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Returns Voicemeeter's version as a string"""
|
||||
return "{0}.{1}.{2}.{3}".format(*self.public_packet.voicemeeterversion)
|
||||
return '{0}.{1}.{2}.{3}'.format(
|
||||
*self.public_packets[NBS.zero].voicemeeterversion
|
||||
)
|
||||
|
||||
@property
|
||||
def pdirty(self):
|
||||
@@ -156,74 +257,58 @@ class VbanCmd(metaclass=ABCMeta):
|
||||
return self._ldirty
|
||||
|
||||
@property
|
||||
def public_packet(self):
|
||||
return self._public_packet
|
||||
def public_packets(self):
|
||||
return self._public_packets
|
||||
|
||||
def clear_dirty(self) -> None:
|
||||
while self.pdirty:
|
||||
time.sleep(self.DELAY)
|
||||
|
||||
def _get_levels(self, packet) -> Iterable:
|
||||
"""
|
||||
returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
|
||||
def apply(self, data: Mapping):
|
||||
"""Set all parameters of a dict"""
|
||||
|
||||
strip levels in PREFADER mode.
|
||||
"""
|
||||
return (
|
||||
packet.inputlevels,
|
||||
packet.outputlevels,
|
||||
)
|
||||
def target(key):
|
||||
match key.split('-'):
|
||||
case ['strip' | 'bus' as kls, index] if index.isnumeric():
|
||||
target = getattr(self, kls)
|
||||
case [
|
||||
'vban',
|
||||
'in' | 'instream' | 'out' | 'outstream' as direction,
|
||||
index,
|
||||
] if index.isnumeric():
|
||||
target = getattr(
|
||||
self.vban, f'{direction.removesuffix("stream")}stream'
|
||||
)
|
||||
case _:
|
||||
ERR_MSG = f"invalid config key '{key}'"
|
||||
self.logger.error(ERR_MSG)
|
||||
raise ValueError(ERR_MSG)
|
||||
return target[int(index)]
|
||||
|
||||
def apply(self, data: dict):
|
||||
"""
|
||||
Sets all parameters of a dict
|
||||
|
||||
minor delay between each recursion
|
||||
"""
|
||||
|
||||
def param(key):
|
||||
obj, m2, *rem = key.split("-")
|
||||
index = int(m2) if m2.isnumeric() else int(*rem)
|
||||
if obj in ("strip", "bus", "button"):
|
||||
return getattr(self, obj)[index]
|
||||
elif obj == "vban":
|
||||
return getattr(getattr(self, obj), f"{m2}stream")[index]
|
||||
raise ValueError(obj)
|
||||
|
||||
[param(key).apply(datum).then_wait() for key, datum in data.items()]
|
||||
for key, di in data.items():
|
||||
target(key).apply(di)
|
||||
|
||||
def apply_config(self, name):
|
||||
"""applies a config from memory"""
|
||||
ERR_MSG = (
|
||||
f"No config with name '{name}' is loaded into memory",
|
||||
f"Known configs: {list(self.configs.keys())}",
|
||||
f'Known configs: {list(self.configs.keys())}',
|
||||
)
|
||||
try:
|
||||
config = self.configs[name]
|
||||
except KeyError as e:
|
||||
self.logger.error(("\n").join(ERR_MSG))
|
||||
raise VBANCMDError(("\n").join(ERR_MSG)) from e
|
||||
self.logger.error(('\n').join(ERR_MSG))
|
||||
raise VBANCMDError(('\n').join(ERR_MSG)) from e
|
||||
|
||||
if "extends" in config:
|
||||
extended = config["extends"]
|
||||
if 'extends' in config:
|
||||
extended = config['extends']
|
||||
config = {
|
||||
k: v
|
||||
for k, v in deep_merge(self.configs[extended], config)
|
||||
if k not in ("extends")
|
||||
if k not in ('extends')
|
||||
}
|
||||
self.logger.debug(
|
||||
f"profile '{name}' extends '{extended}', profiles merged.."
|
||||
)
|
||||
self.apply(config)
|
||||
self.logger.info(f"Profile '{name}' applied!")
|
||||
|
||||
def logout(self) -> None:
|
||||
if not self.stopped():
|
||||
self.logger.debug("events thread shutdown started")
|
||||
self.stop_event.set()
|
||||
self.subscriber.join() # wait for subscriber thread to complete cycle
|
||||
[sock.close() for sock in self.socks]
|
||||
self.logger.info(f"{type(self).__name__}: Successfully logged out of {self}")
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
self.logout()
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .enums import NBS
|
||||
from .error import VBANCMDConnectionError
|
||||
from .packet import HEADER_SIZE, SubscribeHeader, VbanRtPacket, VbanRtPacketHeader
|
||||
from .util import Socket
|
||||
from .packet.headers import (
|
||||
HEADER_SIZE,
|
||||
VbanRTPacket,
|
||||
VbanRTResponseHeader,
|
||||
VbanRTSubscribeHeader,
|
||||
)
|
||||
from .packet.nbs0 import VbanRTPacketNBS0
|
||||
from .packet.nbs1 import VbanRTPacketNBS1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,29 +20,23 @@ class Subscriber(threading.Thread):
|
||||
"""fire a subscription packet every 10 seconds"""
|
||||
|
||||
def __init__(self, remote, stop_event):
|
||||
super().__init__(name="subscriber", daemon=False)
|
||||
super().__init__(name='subscriber', daemon=False)
|
||||
self._remote = remote
|
||||
self.stop_event = stop_event
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self.packet = SubscribeHeader()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped():
|
||||
try:
|
||||
self._remote.socks[Socket.register].sendto(
|
||||
self.packet.header,
|
||||
(socket.gethostbyname(self._remote.ip), self._remote.port),
|
||||
for nbs in NBS:
|
||||
sub_packet = VbanRTSubscribeHeader().to_bytes(
|
||||
nbs, self._remote._get_next_framecounter()
|
||||
)
|
||||
self.packet.framecounter = (
|
||||
int.from_bytes(self.packet.framecounter, "little") + 1
|
||||
).to_bytes(4, "little")
|
||||
self.wait_until_stopped(10)
|
||||
except socket.gaierror as e:
|
||||
self.logger.exception(f"{type(e).__name__}: {e}")
|
||||
raise VBANCMDConnectionError(
|
||||
f"unable to resolve hostname {self._remote.ip}"
|
||||
) from e
|
||||
self.logger.debug(f"terminating {self.name} thread")
|
||||
self._remote.sock.sendto(
|
||||
sub_packet, (self._remote.host, self._remote.port)
|
||||
)
|
||||
|
||||
self.wait_until_stopped(10)
|
||||
self.logger.debug(f'terminating {self.name} thread')
|
||||
|
||||
def stopped(self):
|
||||
return self.stop_event.is_set()
|
||||
@@ -54,93 +53,76 @@ class Producer(threading.Thread):
|
||||
"""Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
|
||||
|
||||
def __init__(self, remote, queue, stop_event):
|
||||
super().__init__(name="producer", daemon=False)
|
||||
super().__init__(name='producer', daemon=False)
|
||||
self._remote = remote
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
self.packet_expected = VbanRtPacketHeader()
|
||||
self._remote.socks[Socket.response].settimeout(self._remote.timeout)
|
||||
self._remote.socks[Socket.response].bind(
|
||||
(socket.gethostbyname(socket.gethostname()), self._remote.port)
|
||||
)
|
||||
self._remote._public_packet = self._get_rt()
|
||||
self._remote._public_packets = [None] * (max(NBS) + 1)
|
||||
_pp = self._get_rt()
|
||||
self._remote._public_packets[_pp.nbs] = _pp
|
||||
(
|
||||
self._remote.cache["strip_level"],
|
||||
self._remote.cache["bus_level"],
|
||||
) = self._remote._get_levels(self._remote.public_packet)
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
) = 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"""
|
||||
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():
|
||||
data = None
|
||||
while not data:
|
||||
data = self._fetch_rt_packet()
|
||||
return data
|
||||
try:
|
||||
header = VbanRTResponseHeader.from_bytes(data[:HEADER_SIZE])
|
||||
except ValueError as e:
|
||||
self.logger.debug(f'Error parsing response packet: {e}')
|
||||
continue
|
||||
|
||||
return fget()
|
||||
|
||||
def _fetch_rt_packet(self) -> Optional[VbanRtPacket]:
|
||||
try:
|
||||
data, _ = self._remote.socks[Socket.response].recvfrom(2048)
|
||||
# do we have packet data?
|
||||
if len(data) > HEADER_SIZE:
|
||||
# is the packet of type VBAN RT response?
|
||||
if self.packet_expected.header == data[:HEADER_SIZE]:
|
||||
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],
|
||||
match header.format_nbs:
|
||||
case NBS.zero:
|
||||
return VbanRTPacketNBS0.from_bytes(
|
||||
nbs=NBS.zero, kind=self._remote.kind, data=data
|
||||
)
|
||||
|
||||
case NBS.one:
|
||||
return VbanRTPacketNBS1.from_bytes(
|
||||
nbs=NBS.one, kind=self._remote.kind, data=data
|
||||
)
|
||||
except TimeoutError as e:
|
||||
self.logger.exception(f"{type(e).__name__}: {e}")
|
||||
raise VBANCMDConnectionError(
|
||||
f"timeout waiting for RtPacket from {self._remote.ip}"
|
||||
) from e
|
||||
|
||||
def stopped(self):
|
||||
return self.stop_event.is_set()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped():
|
||||
pdirty = ldirty = False
|
||||
_pp = self._get_rt()
|
||||
pdirty = _pp.pdirty(self._remote.public_packet)
|
||||
ldirty = _pp.ldirty(
|
||||
self._remote.cache["strip_level"], self._remote.cache["bus_level"]
|
||||
)
|
||||
match _pp.nbs:
|
||||
case NBS.zero:
|
||||
ldirty = _pp.ldirty(
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
)
|
||||
pdirty = _pp.pdirty(self._remote.public_packets[NBS.zero])
|
||||
case NBS.one:
|
||||
pdirty = True
|
||||
|
||||
if pdirty or ldirty:
|
||||
self._remote._public_packet = _pp
|
||||
self._remote._public_packets[_pp.nbs] = _pp
|
||||
self._remote._pdirty = pdirty
|
||||
self._remote._ldirty = ldirty
|
||||
|
||||
if self._remote.event.pdirty:
|
||||
self.queue.put("pdirty")
|
||||
self.queue.put('pdirty')
|
||||
if self._remote.event.ldirty:
|
||||
self.queue.put("ldirty")
|
||||
time.sleep(self._remote.ratelimit)
|
||||
self.logger.debug(f"terminating {self.name} thread")
|
||||
self.queue.put('ldirty')
|
||||
self.logger.debug(f'terminating {self.name} thread')
|
||||
self.queue.put(None)
|
||||
|
||||
|
||||
@@ -152,7 +134,7 @@ class Updater(threading.Thread):
|
||||
"""
|
||||
|
||||
def __init__(self, remote, queue):
|
||||
super().__init__(name="updater", daemon=True)
|
||||
super().__init__(name='updater', daemon=True)
|
||||
self._remote = remote
|
||||
self.queue = queue
|
||||
self.logger = logger.getChild(self.__class__.__name__)
|
||||
@@ -166,19 +148,16 @@ class Updater(threading.Thread):
|
||||
Generate _strip_comp, _bus_comp and update level cache if ldirty.
|
||||
"""
|
||||
while event := self.queue.get():
|
||||
if event == "pdirty" and self._remote.pdirty:
|
||||
if event == 'pdirty' and self._remote.pdirty:
|
||||
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._public_packet._strip_comp,
|
||||
self._remote._public_packet._bus_comp,
|
||||
self._remote._public_packets[NBS.zero]._strip_comp,
|
||||
self._remote._public_packets[NBS.zero]._bus_comp,
|
||||
)
|
||||
(
|
||||
self._remote.cache["strip_level"],
|
||||
self._remote.cache["bus_level"],
|
||||
) = (
|
||||
self._remote._public_packet.inputlevels,
|
||||
self._remote._public_packet.outputlevels,
|
||||
)
|
||||
self._remote.cache['strip_level'],
|
||||
self._remote.cache['bus_level'],
|
||||
) = self._remote.public_packets[NBS.zero].levels
|
||||
self._remote.subject.notify(event)
|
||||
self.logger.debug(f"terminating {self.name} thread")
|
||||
self.logger.debug(f'terminating {self.name} thread')
|
||||
|
||||
Reference in New Issue
Block a user