183 Commits

Author SHA1 Message Date
69263c22f2 add 2.7.0 to CHANGELOG 2026-03-01 17:04:37 +00:00
ad2cfeaae6 entry point now accepts a 'matrix' kind although it's main purpose is to disable the rt listener threads.
{VbanCmd}.sendtext():
- remove the @script decorator which I'm sure nobody has ever used anyway
- if rt listeners are disabled and it's a matrix query request, attempt to read a response.
2026-03-01 16:21:47 +00:00
1123fe6432 move header validation into class methods
add _parse_vban_service_header() helper function
2026-03-01 16:17:03 +00:00
3c3e415d7e add _send_request() helper method. 2026-03-01 11:09:45 +00:00
8cfeb45fcb update imports 2026-03-01 11:09:28 +00:00
10b38b3fcc move packet classes into internal packet module 2026-03-01 11:09:22 +00:00
ff5ac193c8 add ChannelState interface, use it in the meta functions.
reword busmodes bitwise logic.

comment out ratelimit, this will probably get permanently removed.
2026-03-01 03:37:57 +00:00
2f3cd0e07f use db levels throughout the package. This is cleaner than converting to db but comparing raw integer values. 2026-03-01 01:08:02 +00:00
d689b3a301 move voicemeetertype(), voicemeeterversion() and samplerate() properties into VbanPacket
add NamedTuples for Levels, Labels and States.

refactor the levels properties

update the math in util.comp()

StripLevel/BusLevel getters updated according to changes in VbanPacketNBS0

remove {VbanCmd}._get_levels(), it's no longer necessary.
2026-03-01 00:25:22 +00:00
a8ef82166c upd publish action 2026-02-27 20:59:25 +00:00
79f06ecc79 add ruff+publish workflows 2026-02-27 20:57:54 +00:00
b291c3a477 minor version bump 2026-02-27 20:36:54 +00:00
c335d35b9f fix config extends section 2026-02-27 20:16:04 +00:00
911d2f64a6 import abc namespace 2026-02-08 09:09:59 +00:00
e58d6c7242 remove comments 2026-01-18 19:57:12 +00:00
870a95b41e upd Strip Comp/Gate/EQ in README 2026-01-18 18:08:40 +00:00
59880bf582 remove comments 2026-01-18 17:22:20 +00:00
cc58d1f081 implement {strip}.gate 2026-01-18 17:06:10 +00:00
e37dea38b3 upd Run Tests in README 2026-01-18 15:25:05 +00:00
7f3b0ac7c9 upd examples to read conn from env 2026-01-18 15:17:00 +00:00
0512fac710 implement parametric eq 2026-01-18 15:16:48 +00:00
d439da725c implement parametric eq 2026-01-18 14:42:07 +00:00
45ffed9f63 implement audibility knobs (inc comp, gate, denoiser) 2026-01-18 13:13:05 +00:00
14f79d1388 move namedtuples 2026-01-18 12:22:53 +00:00
b45bd38706 use namedtuples to improve readability 2026-01-18 12:19:16 +00:00
312b5c5842 refactor header dataclasses 2026-01-18 11:43:43 +00:00
ed8e281f7f remove unused func 2026-01-17 13:25:06 +00:00
efdcfce387 refactor gainlayers and bus gains 2026-01-17 13:19:43 +00:00
ad88286509 implement 3d parameters 2026-01-17 12:29:10 +00:00
ecbdd2778f add classmethod from_bytes() to both RT packets NBS0/NBS1 2026-01-17 10:06:28 +00:00
1babf85a89 upd poethepoet ver
have poe read from .env file
rename script tasks

add py313 to tox env list
2026-01-17 09:38:44 +00:00
fbd1d54f9b upd tests 2026-01-17 09:37:50 +00:00
96e9d6f4fd upd the interface to read/write multiple private/public packets.
{VirtualStrip}.bass/mid/treble implemented reading from public packet NBS=1
2026-01-17 09:37:31 +00:00
51394c0076 add VbanVMParamStrip defining the VMPARAMSTRIP_PACKET struct. 2026-01-17 09:35:33 +00:00
91feccc509 default bps to 256000 (same as VBAN-Text-Client SDK example) see https://github.com/vburel2018/VBAN-Text-Client
patch bump
2025-01-25 02:06:42 +00:00
c9c365ac54 rename test poe scripts
add passenv = * to [testenv]
2025-01-25 02:00:54 +00:00
1742ff839e add ip kwarg default value 2025-01-21 16:23:59 +00:00
5299d9ec6b log factory steps at debug level 2025-01-17 20:49:39 +00:00
bc2cd3e7a5 test against localhost
run tests through formatter

remove sel test from bus bool params
2025-01-17 15:01:40 +00:00
af68c423a6 run example snippets through ruff formatter 2025-01-17 02:54:23 +00:00
16df0d559e make changes to sockets.
replace black+isort with ruff

upd examples
2025-01-17 02:51:17 +00:00
dad5ee9e9d Merge pull request #4 from onyx-and-iris/dependabot/pip/virtualenv-20.26.6
Bump virtualenv from 20.23.1 to 20.26.6
2025-01-13 21:58:18 +00:00
dependabot[bot]
694e1036de Bump virtualenv from 20.23.1 to 20.26.6
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.23.1 to 20.26.6.
- [Release notes](https://github.com/pypa/virtualenv/releases)
- [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/virtualenv/compare/20.23.1...20.26.6)

---
updated-dependencies:
- dependency-name: virtualenv
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-13 19:04:17 +00:00
8436634371 bit shift bus modes.
make modelist a BusMixin attr

remove vban.public_packet from README. It should be used only internally.

patch bump
2024-07-05 17:43:28 +01:00
074ba4fe77 Merge pull request #3 from onyx-and-iris/dependabot/pip/black-24.3.0
Bump black from 22.8.0 to 24.3.0
2024-04-05 03:37:24 +01:00
dependabot[bot]
2b4e64ed76 Bump black from 22.8.0 to 24.3.0
Bumps [black](https://github.com/psf/black) from 22.8.0 to 24.3.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/22.8.0...24.3.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-05 02:36:56 +00:00
21df4998a2 upd pytest dep version 2024-02-15 18:47:44 +00:00
7bff293820 py 12 added to tox envs 2024-02-08 12:13:30 +00:00
c8d0a0078d adds more logging for getters 2023-10-21 18:02:55 +01:00
87a1d62414 ensure we don't try to join a thread that wasn't created
(in the event of a timeout error)
2023-10-21 07:48:09 +01:00
f863723a4e add group dev (dev-dependencies deprecated) 2023-08-27 19:04:18 +01:00
afa1867abc add poetry badge 2023-08-19 19:56:35 +01:00
fcb656b7d0 reword docstring 2023-08-19 19:56:17 +01:00
9c0e2bef39 2.4.9 section added to CHANGELOG
patch bump
2023-08-13 18:20:28 +01:00
36692d1bc7 fixes error with escape character in regex 2023-08-13 18:16:49 +01:00
753714b639 should the loader attempt to load an invalid toml config
log as error but allow the loader to continue
2023-08-13 18:16:33 +01:00
27a26b8fe9 remove __str__ override 2023-08-13 18:15:31 +01:00
79260a0e47 check vban direction
check that index is numeric

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

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

test badges updated

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

move SubscribeHeader above  VbanRtPacketHeader

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

factory tests updated.

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

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

2.3.0 section added to CHANGELOG

three example extender.toml configs added

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

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

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

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

filter out all logs but `vban_cmd.iremote`

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

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

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

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

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

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

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

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

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

update README

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

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

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

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

info message fixed if no events subbed to

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

python version requirement changed.

tomli added as runtime dependency if py ver < 3.11

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

patch bump
2022-08-10 17:49:21 +01:00
56 changed files with 4414 additions and 1460 deletions

53
.github/workflows/publish.yml vendored Normal file
View 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
View 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'

14
.gitignore vendored
View File

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

View File

@@ -11,6 +11,183 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass
- [x] - [x]
## [2.7.0] - 2026-03-01
### Added
- new kind `matrix` has been added, it does two things:
- scales the interface according to `potato` kind, in practice this has no affect but it's required by the builder classes.
- disables the rt listener threads since we aren't expecting to receive any from a Matrix VBAN server.
- however, matrix responses may still be received with the {VbanCmd}.sendtext() method.
### Changed
- `outbound` kwarg has been renamed to `disable_rt_listeners`. Since it's job is to disable the listener threads for incoming RT packets this new name is more descriptive.
- dataclasses representing packet headers and packets with ident:0 and ident:1 have been moved into an internal packet module.
### Removed
- {VbanCmd}.sendtext() @script decorator removed. It's purpose was to attempt to convert a dictionary to a script but it was poorly implemented and there exists the {VbanCmd}.apply() method already.
## [2.6.0] - 2026-02-26
### Added
- support for packet with [ident:1](https://github.com/onyx-and-iris/Voicemeeter-SDK/blob/3be2c1c36563afbd6df3da8436406c77d2cc1f10/VoicemeeterRemote.h#L982) in VBAN TEXT subprotocol.
- This includes Strip 3D, PEQ, comp, gate, denoiser and pitch parameters.
## [2.5.2] - 2025-01-25
### Changed
- ip kwargs defaults to 'localhost'
- bps kwarg defaults to 256000.
- factory builder steps now logged at `DEBUG` level.
- Internal socket changes, they don't affect interface usage.
## [2.4.9] - 2023-08-13
### Added
- Error tests added in tests/test_errors.py
- Errors section in README updated.
### Changed
- VBANCMDConnectionError class now subclasses VBANCMDError
- If the configs loader is passed an invalid config TOML it will log an error but continue to load further configs into memory.
## [2.3.2] - 2023-07-12
### Added
- vban.{instream,outstream} tuples now contain classes that represent MIDI and TEXT streams.
### Fixed
- apply_config() now performs a deep merge when extending a config with another.
## [2.3.0] - 2023-07-11
### Added
- user configs may now extend other user configs. check `config extends` section in README.
## [2.2.0] - 2023-07-08
### Added
- button, vban classes implemented
- \__repr\__() method added to base class
## [2.1.2] - 2023-07-05
### Added
- `outbound` kwarg let's you disable incoming rt packets. Essentially the interface will work only in one direction.
This is useful if you are only interested in sending commands out to voicemeeter but don't need to receive parameter states.
By default outbound is False.
- sendtext logging added in base class.
### Fixed
- Bug in apply() if invoked from a higher class (not base class)
## [2.0.0] - 2023-06-25
This update introduces some breaking changes:
### Changed
- `strip[i].comp` now references StripComp class
- To change the comp knob you should now use the property `strip[i].comp.knob`
- `strip[i].gate` now references StripGate class
- To change the gate knob you should now use the property `strip[i].gate.knob`
- `bus[i].eq` now references BusEQ class
- To set bus[i].{eq,eq_ab} as before you should now use bus[i].eq.on and bus[i].eq.ab
- new error class `VBANCMDConnectionError` raised when a connection fails or times out.
There are other non-breaking changes:
### Changed
- now using a producer thread to send events to the updater thread.
- factory.request_vbancmd_obj simply raises a `VBANCMDError` if passed an incorrect kind.
- module level loggers implemented (with class loggers as child loggers)
### Added
- `strip[i].eq` added to PhysicalStrip
## [1.8.0]
### Added
- Connection section to README.
### Changed
- now using clear_dirty() when sync enabled.
### Fixed
- bug in set_rt() where multiple commands sent in single request packet.
- bug in apply where index was sent twice.
## [1.7.0]
### Added
- ability to read conn info from vban.toml config
### Changed
- assume a vban.toml in examples. README's modified.
## [1.6.0] - 2022-10-06
### Added
- fadeto(), fadeby() methods added to strip/bus classes.
- OBS example added.
### Changed
- Event class add/remove now accept iterables.
- property setters added to Event class.
- ldirty logic moved into VbanRtPacket class.
- in util, threshold a level is considered dirty moved to 7200 (-72.0)
- now print bus levels in observer example.
### Fixed
- initialize comps in updater thread. fixes bug when switching to a kind before any level updates
## [1.5.0] - 2022-09-28
### Changed
- Logging module used in place of print statements across the interface.
- base error name changed (VBANCMDError)
### Fixed
- Timeout and raise connection error when socket connection fails.
- Bug in observer example
## [1.4.0] - 2022-09-03
### Added
- tomli/tomllib compatibility layer to support python 3.10
## [1.3.0] - 2022-08-02 ## [1.3.0] - 2022-08-02
### Added ### Added

379
README.md
View File

@@ -1,48 +1,61 @@
[![PyPI version](https://badge.fury.io/py/vban-cmd.svg)](https://badge.fury.io/py/vban-cmd) [![PyPI version](https://badge.fury.io/py/vban-cmd.svg)](https://badge.fury.io/py/vban-cmd)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/onyx-and-iris/vban-cmd-python/blob/dev/LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/)
[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
![Tests Status](./tests/basic.svg?dummy=8484744) ![Tests Status](./tests/basic.svg?dummy=8484744)
![Tests Status](./tests/banana.svg?dummy=8484744) ![Tests Status](./tests/banana.svg?dummy=8484744)
![Tests Status](./tests/potato.svg?dummy=8484744) ![Tests Status](./tests/potato.svg?dummy=8484744)
# VBAN CMD # VBAN CMD
This python interface allows you to get and set Voicemeeter parameter values over a network. This python interface allows you to send Voicemeeter/Matrix commands over a network.
It may be used standalone or to extend the [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python) It offers the same public API as [Voicemeeter Remote Python API](https://github.com/onyx-and-iris/voicemeeter-api-python).
There is no support for audio transfer in this package, only parameters. Only the VBAN SERVICE/TEXT subprotocols are supported, there is no support for AUDIO or MIDI in this package.
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md) For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Tested against ## Tested against
- Basic 1.0.8.4 - Basic 1.1.2.2
- Banana 2.0.6.4 - Banana 2.1.2.2
- Potato 3.0.2.4 - Potato 3.1.2.2
## Requirements ## Requirements
- [Voicemeeter](https://voicemeeter.com/) - [Voicemeeter](https://voicemeeter.com/)
- Python 3.11 or greater - Python 3.10 or greater
## Installation ## Installation
### `Pip` ```console
pip install vban-cmd
Install vban-cmd package from your console ```
`pip install vban-cmd`
## `Use` ## `Use`
#### Connection
Load VBAN connection info from toml config. A valid `vban.toml` might look like this:
```toml
[connection]
ip = "gamepc.local"
port = 6980
streamname = "Command1"
```
It should be placed in \<user home directory\> / "Documents" / "Voicemeeter" / "configs"
Alternatively you may pass `ip`, `port`, `streamname` as keyword arguments.
#### `__main__.py`
Simplest use case, use a context manager to request a VbanCmd class of a kind. Simplest use case, use a context manager to request a VbanCmd class of a kind.
Login and logout are handled for you in this scenario. Login and logout are handled for you in this scenario.
#### `__main__.py`
```python ```python
import vban_cmd import vban_cmd
@@ -52,24 +65,28 @@ class ManyThings:
self.vban = vban self.vban = vban
def things(self): def things(self):
self.vban.strip[0].label = "podmic" self.vban.strip[0].label = 'podmic'
self.vban.strip[0].mute = True self.vban.strip[0].mute = True
print( print(
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}" f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
) )
def other_things(self): def other_things(self):
info = (
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
)
self.vban.bus[3].gain = -6.3 self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True self.vban.bus[4].eq = True
print("\n".join(info)) info = (
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
)
print('\n'.join(info))
def main(): def main():
with vban_cmd.api(kind_id, **opts) as vban: KIND_ID = 'banana'
with vban_cmd.api(
KIND_ID, ip='gamepc.local', port=6980, streamname='Command1'
) as vban:
do = ManyThings(vban) do = ManyThings(vban)
do.things() do.things()
do.other_things() do.other_things()
@@ -77,33 +94,29 @@ def main():
# set many parameters at once # set many parameters at once
vban.apply( vban.apply(
{ {
"strip-2": {"A1": True, "B1": True, "gain": -6.0}, 'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
"bus-2": {"mute": True}, 'bus-2': {'mute': True},
'vban-in-0': {'on': True},
} }
) )
if __name__ == "__main__": if __name__ == '__main__':
kind_id = "banana"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main() main()
``` ```
Otherwise you must remember to call `vban.login()`, `vban.logout()` at the start/end of your code. Otherwise you must remember to call `vban.login()`, `vban.logout()` at the start/end of your code.
## `kind_id` ## `KIND_ID`
Pass the kind of Voicemeeter as an argument. kind_id may be: Pass the kind of Voicemeeter as an argument. KIND_ID may be:
- `basic` - `basic`
- `banana` - `banana`
- `potato` - `potato`
A fourth kind `matrix` has been added, if you pass it as a KIND_ID you are expected to use the [{VbanCmd}.sendtext()](https://github.com/onyx-and-iris/vban-cmd-python?tab=readme-ov-file#vbansendtextscript) method for sending text requests.
## `Available commands` ## `Available commands`
### Strip ### Strip
@@ -116,17 +129,116 @@ The following properties are available.
- `label`: string - `label`: string
- `gain`: float, -60 to 12 - `gain`: float, -60 to 12
- `A1 - A5`, `B1 - B3`: boolean - `A1 - A5`, `B1 - B3`: boolean
- `comp`: float, from 0.0 to 10.0
- `gate`: float, from 0.0 to 10.0
- `limit`: int, from -40 to 12 - `limit`: int, from -40 to 12
example: example:
```python ```python
vban.strip[3].gain = 3.7 vban.strip[3].gain = 3.7
print(strip[0].label) print(vban.strip[0].label)
``` ```
The following methods are available.
- `appgain(name, value)`: string, float, from 0.0 to 1.0
Set the gain in db by value for the app matching name.
- `appmute(name, value)`: string, bool
Set mute state as value for the app matching name.
example:
```python
vban.strip[5].appmute('Spotify', True)
vban.strip[5].appgain('Spotify', 0.5)
```
##### Strip.Comp
The following properties are available.
- `knob`: float, from 0.0 to 10.0
- `gainin`: float, from -24.0 to 24.0
- `ratio`: float, from 1.0 to 8.0
- `threshold`: float, from -40.0 to -3.0
- `attack`: float, from 0.0 to 200.0
- `release`: float, from 0.0 to 5000.0
- `knee`: float, from 0.0 to 1.0
- `gainout`: float, from -24.0 to 24.0
- `makeup`: boolean
example:
```python
print(vban.strip[4].comp.knob)
```
Strip Comp `knob` is defined for all versions, all other parameters potato only.
##### Strip.Gate
The following properties are available.
- `knob`: float, from 0.0 to 10.0
- `threshold`: float, from -60.0 to -10.0
- `damping`: float, from -60.0 to -10.0
- `bpsidechain`: int, from 100 to 4000
- `attack`: float, from 0.0 to 1000.0
- `hold`: float, from 0.0 to 5000.0
- `release`: float, from 0.0 to 5000.0
example:
```python
vban.strip[2].gate.attack = 300.8
```
Strip Gate `knob` is defined for all versions, all other parameters potato only.
##### Strip.Denoiser
The following properties are available.
- `knob`: float, from 0.0 to 10.0
strip.denoiser properties are defined as write only, potato version only.
##### Strip.EQ
The following properties are available.
- `on`: boolean
- `ab`: boolean
example:
```python
vban.strip[0].eq.ab = True
```
##### Strip.EQ.Channel.Cell
The following properties are available.
- `on`: boolean
- `type`: int, from 0 up to 6
- `f`: float, from 20.0 up to 20_000.0
- `gain`: float, from -36.0 up to 18.0
- `q`: float, from 0.3 up to 100
example:
```python
vban.strip[0].eq.channel[0].cell[2].on = True
vban.strip[1].eq.channel[0].cell[2].f = 5000
```
Strip EQ parameters are defined for PhysicalStrips, potato version only.
Only channel[0] properties are readable over VBAN.
##### Gainlayers ##### Gainlayers
- `gain`: float, from -60.0 to 12.0 - `gain`: float, from -60.0 to 12.0
@@ -158,8 +270,6 @@ Level properties will return -200.0 if no audio detected.
The following properties are available. The following properties are available.
- `mono`: boolean - `mono`: boolean
- `eq`: boolean
- `eq_ab`: boolean
- `mute`: boolean - `mute`: boolean
- `label`: string - `label`: string
- `gain`: float, -60 to 12 - `gain`: float, -60 to 12
@@ -167,10 +277,20 @@ The following properties are available.
example: example:
```python ```python
vban.bus[4].eq = true
print(vban.bus[0].label) print(vban.bus[0].label)
``` ```
##### Bus.EQ
The following properties are available.
- `on`: boolean
- `ab`: boolean
```python
vban.bus[4].eq.on = true
```
##### Modes ##### Modes
The following properties are available. The following properties are available.
@@ -213,6 +333,22 @@ print(vban.bus[0].levels.all)
`levels.all` will return -200.0 if no audio detected. `levels.all` will return -200.0 if no audio detected.
### Strip | Bus
The following methods are available.
- `fadeto(amount, time)`: float, int
- `fadeby(amount, time)`: float, int
Modify gain to or by the selected amount in db over a time interval in ms.
example:
```python
vban.strip[0].fadeto(-10.3, 1000)
vban.bus[3].fadeby(-5.6, 500)
```
### Command ### Command
Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available: Certain 'special' commands are defined by the API as performing actions rather than setting values. The following methods are available:
@@ -242,8 +378,10 @@ vban.command.showvbanchat = true
```python ```python
vban.apply( vban.apply(
{ {
"strip-2": {"A1": True, "B1": True, "gain": -6.0}, 'strip-0': {'A1': True, 'B1': True, 'gain': -6.0},
"bus-2": {"mute": True}, 'bus-1': {'mute': True, 'mode': 'composite'},
'bus-2': {'eq': {'on': True}},
'vban-in-0': {'on': True},
} }
) )
``` ```
@@ -251,8 +389,8 @@ vban.apply(
Or for each class you may do: Or for each class you may do:
```python ```python
vban.strip[0].apply(mute: true, gain: 3.2, A1: true) vban.strip[0].apply({'mute': True, 'gain': 3.2, 'A1': True})
vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24) vban.vban.outstream[0].apply({'on': True, 'name': 'streamname', 'bit': 24})
``` ```
## Config Files ## Config Files
@@ -261,7 +399,7 @@ vban.vban.outstream[0].apply(on: true, name: 'streamname', bit: 24)
You may load config files in TOML format. You may load config files in TOML format.
Three example configs have been included with the package. Remember to save Three example configs have been included with the package. Remember to save
current settings before loading a user config. To set one you may do: current settings before loading a user config. To load one you may do:
```python ```python
import vban_cmd import vban_cmd
@@ -271,64 +409,115 @@ with vban_cmd.api('banana') as vban:
will load a config file at configs/banana/example.toml for Voicemeeter Banana. will load a config file at configs/banana/example.toml for Voicemeeter Banana.
## `Base Module` Your configs may be located in one of the following paths:
- \<current working directory\> / "configs" / kind_id
- \<user home directory\> / ".config" / "vban-cmd" / kind_id
- \<user home directory\> / "Documents" / "Voicemeeter" / "configs" / kind_id
### VbanCmd class If a config with the same name is located in multiple locations, only the first one found is loaded into memory, in the above order.
`vban_cmd.api(kind_id: str, **opts: dict)` #### `config extends`
You may pass the following optional keyword arguments: You may also load a config that extends another config with overrides or additional parameters.
- `ip`: str, ip or hostname of remote machine You just need to define a key `extends` in the config TOML, that names the config to be extended.
- `streamname`: str, name of the stream to connect to.
- `port`: int=6980, vban udp port of remote machine.
- `subs`: dict={"pdirty": True, "ldirty": False}, controls which updates to listen for.
- `pdirty`: parameter updates
- `ldirty`: level updates
#### Event updates Three example 'extender' configs are included with the repo. You may load them with:
To receive event updates you should do the following: ```python
import vban_cmd
with vban_cmd.api('banana') as vm:
vm.apply_config('extender')
```
- register your app to receive updates using the `vban.subject.add(observer)` method, where observer is your app. ## Events
- define an `on_update(subject)` callback function in your app. The value of subject may be checked for the type of update.
See `examples/observer` for a demonstration. Level updates are considered high volume, by default they are NOT listened for. Use `subs` keyword arg to initialize event updates.
Level updates are considered high volume, by default they are NOT listened for.
Each of the update types may be enabled/disabled separately.
example: example:
```python ```python
import vban_cmd import vban_cmd
# Listen for level updates
opts = { opts = {
"ip": "<ip address>", 'ip': '<ip address>',
"streamname": "Command1", 'streamname': 'Command1',
"port": 6980, 'port': 6980,
"subs": {"ldirty": True},
} }
with vban_cmd.api('banana', **opts) as vban: with vban_cmd.api('banana', ldirty=True, **opts) as vban:
...
```
#### `vban.subject`
Use the Subject class to register an app as event observer.
The following methods are available:
- `add`: registers an app as an event observer
- `remove`: deregisters an app as an event observer
example:
```python
# register an app to receive updates
class App():
def __init__(self, vban):
vban.subject.add(self)
... ...
``` ```
#### `vban.event` #### `vban.event`
You may also add/remove event subscriptions as necessary with the Event class. Use the event class to toggle updates as necessary.
The following properties are available:
- `pdirty`: boolean
- `ldirty`: boolean
example: example:
```python ```python
vban.event.add("ldirty") vban.event.ldirty = True
vban.event.remove("pdirty") vban.event.pdirty = False
```
Or add, remove a list of events.
The following methods are available:
- `add()`
- `remove()`
- `get()`
example:
```python
vban.event.remove(['pdirty', 'ldirty'])
# get a list of currently subscribed # get a list of currently subscribed
print(vban.event.get()) print(vban.event.get())
``` ```
## VbanCmd class
`vban_cmd.api(kind_id: str, **opts)`
You may pass the following optional keyword arguments:
- `ip`: 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).
- `disable_rt_listeners`: boolean=False, set `True` if you don't wish to receive RT packets.
- You can still send Matrix string requests ending with `?` and receive a response.
#### `vban.pdirty` #### `vban.pdirty`
True iff a parameter has been changed. True iff a parameter has been changed.
@@ -342,24 +531,46 @@ True iff a level value has been changed.
Sends a script block as a string request, for example: Sends a script block as a string request, for example:
```python ```python
vban.sendtext("Strip[0].Mute=1;Bus[0].Mono=1") vban.sendtext('Strip[0].Mute=1;Bus[0].Mono=1')
``` ```
#### `vban.public_packet` You can even use it to send matrix commands:
Returns a Voicemeeter rt data packet object. Designed to be used internally by the interface but available for parsing through this read only property object. States not guaranteed to be current (requires use of dirty parameters to confirm). ```python
vban.sendtext('Point(ASIO128.IN[1..4],ASIO128.OUT[1]).dBGain = -3.0')
### `Errors` vban.sendtext('Command.Version = ?')
```
- `errors.VMCMDErrors`: Base VMCMD error class. ## Errors
### `Tests` - `errors.VBANCMDError`: Base VBANCMD Exception class.
- `errors.VBANCMDConnectionError`: Exception raised when connection/timeout errors occur.
First make sure you installed the [development dependencies](https://github.com/onyx-and-iris/vban-cmd-python#installation) ## Logging
Then from tests directory: It's possible to see the messages sent by the interface's setters and getters, may be useful for debugging.
`pytest -v` example:
```python
import vban_cmd
logging.basicConfig(level=logging.DEBUG)
opts = {'ip': 'ip.local', 'port': 6980, 'streamname': 'Command1'}
with vban_cmd.api('banana', **opts) as vban:
...
```
### Run tests
Install [poetry](https://python-poetry.org/docs/#installation) and then:
```powershell
poetry poe test-basic
poetry poe test-banana
poetry poe test-potato
```
## Resources ## Resources

View File

@@ -6,24 +6,28 @@ class ManyThings:
self.vban = vban self.vban = vban
def things(self): def things(self):
self.vban.strip[0].label = "podmic" self.vban.strip[0].label = 'podmic'
self.vban.strip[0].mute = True self.vban.strip[0].mute = True
print( print(
f"strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}" f'strip 0 ({self.vban.strip[0].label}) mute has been set to {self.vban.strip[0].mute}'
) )
def other_things(self): def other_things(self):
info = (
f"bus 3 gain has been set to {self.vban.bus[3].gain}",
f"bus 4 eq has been set to {self.vban.bus[4].eq}",
)
self.vban.bus[3].gain = -6.3 self.vban.bus[3].gain = -6.3
self.vban.bus[4].eq = True self.vban.bus[4].eq = True
print("\n".join(info)) info = (
f'bus 3 gain has been set to {self.vban.bus[3].gain}',
f'bus 4 eq has been set to {self.vban.bus[4].eq}',
)
print('\n'.join(info))
def main(): def main():
with vban_cmd.api(kind_id, **opts) as vban: KIND_ID = 'banana'
with vban_cmd.api(
KIND_ID, ip='gamepc.local', port=6980, streamname='Command1'
) as vban:
do = ManyThings(vban) do = ManyThings(vban)
do.things() do.things()
do.other_things() do.other_things()
@@ -31,21 +35,12 @@ def main():
# set many parameters at once # set many parameters at once
vban.apply( vban.apply(
{ {
"strip-2": {"A1": True, "B1": True, "gain": -6.0}, 'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
"bus-2": {"mute": True}, 'bus-2': {'mute': True},
"button-0": {"state": True}, 'vban-in-0': {'on': True},
"vban-in-0": {"on": True},
"vban-out-1": {"name": "streamname"},
} }
) )
if __name__ == "__main__": if __name__ == '__main__':
kind_id = "banana"
opts = {
"ip": "<ip address>",
"streamname": "Command1",
"port": 6980,
}
main() main()

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,110 @@
import os
import threading
from logging import config
import obsws_python as obsws
import vban_cmd
config.dictConfig(
{
'version': 1,
'formatters': {
'standard': {
'format': '%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s'
}
},
'handlers': {
'stream': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'standard',
}
},
'loggers': {
'vban_cmd.iremote': {
'handlers': ['stream'],
'level': 'DEBUG',
'propagate': False,
}
},
'root': {'handlers': ['stream'], 'level': 'WARNING'},
}
)
class Observer:
def __init__(self, vban, stop_event):
self._vban = vban
self._stop_event = stop_event
self._client = obsws.EventClient()
self._client.callback.register(
(
self.on_current_program_scene_changed,
self.on_exit_started,
)
)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self._client.disconnect()
def on_start(self):
self._vban.strip[0].mute = True
self._vban.strip[1].B1 = True
self._vban.strip[2].B2 = True
def on_brb(self):
self._vban.strip[7].fadeto(0, 500)
self._vban.bus[0].mute = True
def on_end(self):
self._vban.apply(
{
'strip-0': {'mute': True},
'strip-1': {'mute': True, 'B1': False},
'strip-2': {'mute': True, 'B1': False},
}
)
def on_live(self):
self._vban.strip[0].mute = False
self._vban.strip[7].fadeto(-6, 500)
self._vban.strip[7].A3 = True
def on_current_program_scene_changed(self, data):
scene = data.scene_name
print(f'Switched to scene {scene}')
match scene:
case 'START':
self.on_start()
case 'BRB':
self.on_brb()
case 'END':
self.on_end()
case 'LIVE':
self.on_live()
def on_exit_started(self, _):
self._stop_event.set()
def main():
KIND_ID = 'potato'
conn = {
'ip': os.environ.get('VBANCMD_IP', 'localhost'),
'port': int(os.environ.get('VBANCMD_PORT', 6980)),
'streamname': os.environ.get('VBANCMD_STREAMNAME', 'Command1'),
}
with vban_cmd.api(KIND_ID, **conn) as vban:
stop_event = threading.Event()
with Observer(vban, stop_event):
stop_event.wait()
if __name__ == '__main__':
main()

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

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

View File

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

View File

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

513
poetry.lock generated
View File

@@ -1,294 +1,363 @@
[[package]] # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]] [[package]]
name = "attrs" name = "cachetools"
version = "22.1.0" version = "5.5.0"
description = "Classes Without Boilerplate" description = "Extensible memoizing collections and decorators"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "black"
version = "22.6.0"
description = "The uncompromising code formatter."
category = "dev"
optional = false
python-versions = ">=3.6.2"
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
pathspec = ">=0.9.0"
platformdirs = ">=2"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
{file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
]
[package.dependencies] [[package]]
colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "chardet"
version = "5.2.0"
description = "Universal encoding detector for Python 3"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.5" version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "distlib"
version = "0.3.9"
description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
{file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "filelock"
version = "3.16.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
]
[package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "1.1.1" version = "2.0.0"
description = "iniconfig: brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = ">=3.7"
groups = ["dev"]
[[package]] files = [
name = "isort" {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
version = "5.10.1" {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
description = "A Python utility / library to sort Python imports." ]
category = "dev"
optional = false
python-versions = ">=3.6.1,<4.0"
[package.extras]
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
requirements_deprecated_finder = ["pipreqs", "pip-api"]
colors = ["colorama (>=0.4.3,<0.5.0)"]
plugins = ["setuptools"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "21.3" version = "24.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.8"
groups = ["dev"]
[package.dependencies] files = [
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
[[package]] ]
name = "pathspec"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.5.2" version = "4.3.6"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
]
[package.extras] [package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.0.0" version = "1.5.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras] [package.extras]
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"] testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "py" name = "pyenv-inspect"
version = "1.11.0" version = "0.4.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities" description = "An auxiliary library for the virtualenv-pyenv and tox-pyenv-redux plugins"
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyenv-inspect-0.4.0.tar.gz", hash = "sha256:ec429d1d81b67ab0b08a0408414722a79d24fd1845a5b264267e44e19d8d60f0"},
{file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"},
]
[[package]] [[package]]
name = "pyparsing" name = "pyproject-api"
version = "3.0.9" version = "1.8.0"
description = "pyparsing module - Classes and methods to define and execute parsing grammars" description = "API to interact with the python pyproject.toml based projects"
category = "dev"
optional = false optional = false
python-versions = ">=3.6.8" python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"},
{file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"},
]
[package.dependencies]
packaging = ">=24.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
diagrams = ["railroad-diagrams", "jinja2"] docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.1.2" version = "8.3.4"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
]
[package.dependencies] [package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=0.12,<2.0" pluggy = ">=1.5,<2"
py = ">=1.8.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""}
tomli = ">=1.0.0"
[package.extras] [package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "pytest-randomly" name = "pytest-randomly"
version = "3.12.0" version = "3.16.0"
description = "Pytest plugin to randomly order tests and control random.seed." description = "Pytest plugin to randomly order tests and control random.seed."
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"},
{file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"},
]
[package.dependencies] [package.dependencies]
pytest = "*" pytest = "*"
[[package]] [[package]]
name = "pytest-repeat" name = "ruff"
version = "0.9.1" version = "0.9.2"
description = "pytest plugin for repeating tests" description = "An extremely fast Python linter and code formatter, written in Rust."
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=3.7"
groups = ["dev"]
[package.dependencies] files = [
pytest = ">=3.6" {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
{file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
{file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
{file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
{file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
{file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
{file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
{file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
{file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
]
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.0.1" version = "2.2.1"
description = "A lil' TOML parser" description = "A lil' TOML parser"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
groups = ["main", "dev"]
markers = "python_version < \"3.11\""
files = [
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
{file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
{file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
{file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
{file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
{file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
{file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
{file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
{file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
{file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
{file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
{file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
{file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
{file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
{file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
{file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
{file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
{file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
{file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
]
[[package]]
name = "tox"
version = "4.23.2"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38"},
{file = "tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c"},
]
[package.dependencies]
cachetools = ">=5.5"
chardet = ">=5.2"
colorama = ">=0.4.6"
filelock = ">=3.16.1"
packaging = ">=24.1"
platformdirs = ">=4.3.6"
pluggy = ">=1.5"
pyproject-api = ">=1.8"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
virtualenv = ">=20.26.6"
[package.extras]
test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_version < \"3.11\""
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "virtualenv"
version = "20.29.0"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-20.29.0-py3-none-any.whl", hash = "sha256:c12311863497992dc4b8644f8ea82d3b35bb7ef8ee82e6630d76d0197c39baf9"},
{file = "virtualenv-20.29.0.tar.gz", hash = "sha256:6345e1ff19d4b1296954cee076baaf58ff2a12a84a338c62b02eda39f20aa982"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[[package]]
name = "virtualenv-pyenv"
version = "0.5.0"
description = "A virtualenv Python discovery plugin for pyenv-installed interpreters"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-pyenv-0.5.0.tar.gz", hash = "sha256:7b0e5fe3dfbdf484f4cf9b01e1f98111e398db6942237910f666356e6293597f"},
{file = "virtualenv_pyenv-0.5.0-py3-none-any.whl", hash = "sha256:21750247e36c55b3c547cfdeb08f51a3867fe7129922991a4f9c96980c0a4a5d"},
]
[package.dependencies]
pyenv-inspect = ">=0.4,<0.5"
virtualenv = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "2.1"
python-versions = "^3.11" python-versions = ">=3.10"
content-hash = "13366a58ff2f3fa0de2cb1e3de2f66fff612610fa66bb909201ebaa434cce014" content-hash = "13fc9d0eb15d5fc09b54c1c8cd8f528b260259e97ee6813b50ab4724c35d6677"
[metadata.files]
atomicwrites = []
attrs = []
black = [
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
]
click = [
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
]
colorama = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
]
pathspec = [
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
]
pytest-randomly = [
{file = "pytest-randomly-3.12.0.tar.gz", hash = "sha256:d60c2db71ac319aee0fc6c4110a7597d611a8b94a5590918bfa8583f00caccb2"},
{file = "pytest_randomly-3.12.0-py3-none-any.whl", hash = "sha256:f4f2e803daf5d1ba036cc22bf4fe9dbbf99389ec56b00e5cba732fb5c1d07fdd"},
]
pytest-repeat = []
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]

View File

@@ -1,23 +1,138 @@
[tool.poetry] [project]
name = "vban-cmd" name = "vban-cmd"
version = "1.3.2" version = "2.7.0"
description = "Python interface for the VBAN RT Packet Service (Sendtext)" description = "Python interface for the VBAN RT Packet Service (Sendtext)"
authors = ["onyx-and-iris <code@onyxandiris.online>"] authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
license = "MIT" license = { text = "MIT" }
readme = "README.md" readme = "README.md"
repository = "https://github.com/onyx-and-iris/vban-cmd-python" requires-python = ">=3.10"
dependencies = ["tomli (>=2.0.1,<3.0) ; python_version < '3.11'"]
[tool.poetry.dependencies] [tool.poetry.requires-plugins]
python = "^3.11" poethepoet = "^0.35.0"
[tool.poetry.group.dev.dependencies]
[tool.poetry.dev-dependencies] pytest = "^8.3.4"
pytest = "^7.1.2" pytest-randomly = "^3.16.0"
pytest-randomly = "^3.12.0" ruff = "^0.9.2"
pytest-repeat = "^0.9.1" tox = "^4.23.2"
black = "^22.3.0" virtualenv-pyenv = "^0.5.0"
isort = "^5.10.1"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poe]
envfile = ".env"
[tool.poe.tasks]
gui.script = "scripts:ex_gui"
obs.script = "scripts:ex_obs"
observer.script = "scripts:ex_observer"
test-basic.script = "scripts:test_basic"
test-banana.script = "scripts:test_banana"
test-potato.script = "scripts:test_potato"
test-all.script = "scripts:test_all"
[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py310,py311,py312,py313
[testenv]
passenv = *
setenv = VIRTUALENV_DISCOVERY=pyenv
allowlist_externals = poetry
commands =
poetry install -v
poetry run pytest tests/
[testenv:obs]
setenv = VIRTUALENV_DISCOVERY=pyenv
allowlist_externals = poetry
deps = obsws-python
commands =
poetry install -v --without dev
poetry run python examples/obs/
"""
[tool.ruff]
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
# Same as Black.
line-length = 88
indent-width = 4
# Assume Python 3.10
target-version = "py310"
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.format]
# Unlike Black, use single quotes for strings.
quote-style = "single"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
#
# This is currently disabled by default, but it is planned for this
# to be opt-out in the future.
docstring-code-format = false
# Set the line length limit used when formatting code snippets in
# docstrings.
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["E402", "F401"]

35
scripts.py Normal file
View File

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

View File

@@ -1,25 +1,23 @@
import os
import random import random
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
import vban_cmd import vban_cmd
from vban_cmd.kinds import KindId, kinds_all from vban_cmd.kinds import KindId
from vban_cmd.kinds import request_kind_map as kindmap from vban_cmd.kinds import request_kind_map as kindmap
# let's keep things random # get KIND from environment, if not set default to potato
kind_id = random.choice(tuple(kind_id.name.lower() for kind_id in KindId)) KIND_ID = os.environ.get('KIND', 'potato')
opts = { opts = {
"ip": "ws.local", 'ip': os.getenv('VBANCMD_IP', 'localhost'),
"streamname": "workstation", 'streamname': os.getenv('VBANCMD_STREAMNAME', 'Command1'),
"port": 6990, 'port': int(os.getenv('VBANCMD_PORT', 6980)),
"bps": 0,
"sync": True,
} }
vbans = {kind.name: vban_cmd.api(kind.name, **opts) for kind in kinds_all} vban = vban_cmd.api(KIND_ID, **opts)
tests = vbans[kind_id] kind = kindmap(KIND_ID)
kind = kindmap(kind_id)
@dataclass @dataclass
@@ -41,10 +39,10 @@ data = Data()
def setup_module(): def setup_module():
print(f"\nRunning tests for kind [{data.name}]\n", file=sys.stdout) print(f'\nRunning tests for kind [{data.name}]\n', file=sys.stdout)
tests.login() vban.login()
tests.command.reset() vban.command.reset()
def teardown_module(): def teardown_module():
tests.logout() vban.logout()

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,7 +1,7 @@
def pytest_addoption(parser): def pytest_addoption(parser):
parser.addoption( parser.addoption(
"--run-slow", '--run-slow',
action="store_true", action='store_true',
default=False, default=False,
help="Run slow tests", help='Run slow tests',
) )

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

37
tests/test_errors.py Normal file
View File

@@ -0,0 +1,37 @@
import re
import pytest
import vban_cmd
from tests import 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)

View File

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

View File

@@ -1,82 +1,80 @@
import pytest import pytest
from tests import data, tests from tests import data, vban
@pytest.mark.parametrize("value", [False, True]) @pytest.mark.parametrize('value', [False, True])
class TestSetAndGetBoolHigher: class TestSetAndGetBoolHigher:
__test__ = True __test__ = True
"""strip tests, physical and virtual""" """strip tests, physical and virtual"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param", 'index,param',
[ [
(data.phys_in, "mute"), (data.phys_in, 'mute'),
(data.virt_in, "solo"), (data.virt_in, 'solo'),
], ],
) )
def test_it_sets_and_gets_strip_bool_params(self, index, param, value): def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
@pytest.mark.skipif( @pytest.mark.skipif(
data.name == "banana", data.name == 'banana',
reason="Only test if logged into Basic or Potato version", reason='Only test if logged into Basic or Potato version',
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param", 'index,param',
[ [
(data.phys_in, "mc"), (data.phys_in, 'mc'),
], ],
) )
def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value): def test_it_sets_and_gets_strip_bool_params_mc(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param", 'index,param',
[ [
(data.phys_out, "eq"), (data.phys_out, 'mute'),
(data.phys_out, "mute"),
(data.virt_out, "eq_ab"),
(data.virt_out, "sel"),
], ],
) )
def test_it_sets_and_gets_bus_bool_params(self, index, param, value): def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
setattr(tests.bus[index], param, value) assert hasattr(vban.bus[index], param)
assert getattr(tests.bus[index], param) == value setattr(vban.bus[index], param, value)
assert getattr(vban.bus[index], param) == value
""" bus modes tests, physical and virtual """ """ bus modes tests, physical and virtual """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param", 'index,param',
[ [
(data.phys_out, "normal"), (data.phys_out, 'normal'),
(data.phys_out, "amix"), (data.phys_out, 'amix'),
(data.phys_out, "rearonly"), (data.phys_out, 'rearonly'),
(data.virt_out, "normal"), (data.virt_out, 'normal'),
(data.virt_out, "upmix41"), (data.virt_out, 'upmix41'),
(data.virt_out, "composite"), (data.virt_out, 'composite'),
], ],
) )
def test_it_sets_and_gets_bus_bool_params(self, index, param, value): def test_it_sets_and_gets_bus_mode_bool_params(self, index, param, value):
# here it only makes sense to set/get bus modes as True # here it only makes sense to set/get bus modes as True
if not value: if not value:
value = True value = True
setattr(tests.bus[index].mode, param, value) setattr(vban.bus[index].mode, param, value)
assert getattr(tests.bus[index].mode, param) == value assert getattr(vban.bus[index].mode, param) == value
""" command tests """ """ command tests """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"param", 'param',
[("lock")], [('lock')],
) )
def test_it_sets_command_bool_params(self, param, value): def test_it_sets_command_bool_params(self, param, value):
setattr(tests.command, param, value) setattr(vban.command, param, value)
class TestSetAndGetIntHigher: class TestSetAndGetIntHigher:
@@ -87,15 +85,15 @@ class TestSetAndGetIntHigher:
"""strip tests, physical and virtual""" """strip tests, physical and virtual"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param,value", 'index,param,value',
[ [
(data.virt_in, "k", 0), (data.virt_in, 'k', 0),
(data.virt_in, "k", 4), (data.virt_in, 'k', 4),
], ],
) )
def test_it_sets_and_gets_strip_bool_params(self, index, param, value): def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
class TestSetAndGetFloatHigher: class TestSetAndGetFloatHigher:
@@ -104,31 +102,33 @@ class TestSetAndGetFloatHigher:
"""strip tests, physical and virtual""" """strip tests, physical and virtual"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,param,value", 'index,param,value',
[ [
(data.phys_in, "gain", -3.6), (data.phys_in, 'gain', -3.6),
(data.phys_in, "gain", 3.6), (data.phys_in, 'gain', 3.6),
(data.virt_in, "gain", -5.8), (data.virt_in, 'gain', -5.8),
(data.virt_in, "gain", 5.8), (data.virt_in, 'gain', 5.8),
], ],
) )
def test_it_sets_and_gets_strip_float_params(self, index, param, value): def test_it_sets_and_gets_strip_float_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,value", 'index,value',
[(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)], [(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
) )
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value): def test_it_gets_strip_prefader_levels_and_compares_length_of_array(
assert len(tests.strip[index].levels.prefader) == value self, index, value
):
assert len(vban.strip[index].levels.prefader) == value
@pytest.mark.skipif( @pytest.mark.skipif(
data.name != "potato", data.name != 'potato',
reason="Only test if logged into Potato version", reason='Only test if logged into Potato version',
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index, j, value", 'index, j, value',
[ [
(data.phys_in, 0, -20.7), (data.phys_in, 0, -20.7),
(data.virt_in, 3, -60), (data.virt_in, 3, -60),
@@ -137,61 +137,96 @@ class TestSetAndGetFloatHigher:
], ],
) )
def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value): def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value):
tests.strip[index].gainlayer[j].gain = value vban.strip[index].gainlayer[j].gain = value
assert tests.strip[index].gainlayer[j].gain == value assert vban.strip[index].gainlayer[j].gain == value
""" strip tests, physical """
@pytest.mark.skipif(
data.name != 'potato',
reason='Only test if logged into Potato version',
)
@pytest.mark.parametrize(
'index, param, value',
[
(data.phys_in, 'gainin', -8.6),
(data.phys_in, 'knee', 0.24),
],
)
def test_it_sets_strip_comp_params(self, index, param, value):
assert hasattr(vban.strip[index].comp, param)
setattr(vban.strip[index].comp, param, value)
# we can set but not get this value. Not in RT Packet.
@pytest.mark.skipif(
data.name != 'potato',
reason='Only test if logged into Potato version',
)
@pytest.mark.parametrize(
'index, param, value',
[
(data.phys_in, 'bpsidechain', 120),
(data.phys_in, 'hold', 3000),
],
)
def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
assert hasattr(vban.strip[index].gate, param)
setattr(vban.strip[index].gate, param, value)
# we can set but not get this value. Not in RT Packet.
""" strip tests, virtual """ """ strip tests, virtual """
@pytest.mark.skip(reason='Requires RT Packet NBS 1')
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index, param, value", 'index, param, value',
[ [
(data.virt_in, "treble", -1.6), (data.virt_in, 'treble', -1.6),
(data.virt_in, "mid", 5.8), (data.virt_in, 'mid', 5.8),
(data.virt_in, "bass", -8.1), (data.virt_in, 'bass', -8.1),
], ],
) )
def test_it_sets_and_gets_strip_eq_params(self, index, param, value): def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index, param, value", 'index, param, value',
[(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)], [(data.phys_out, 'gain', -3.6), (data.virt_out, 'gain', 5.8)],
) )
def test_it_sets_and_gets_bus_float_params(self, index, param, value): def test_it_sets_and_gets_bus_float_params(self, index, param, value):
setattr(tests.bus[index], param, value) setattr(vban.bus[index], param, value)
assert getattr(tests.bus[index], param) == value assert getattr(vban.bus[index], param) == value
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index,value", 'index,value',
[(data.phys_out, 8), (data.virt_out, 8)], [(data.phys_out, 8), (data.virt_out, 8)],
) )
def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value): def test_it_gets_bus_levels_and_compares_length_of_array(self, index, value):
assert len(tests.bus[index].levels.all) == value assert len(vban.bus[index].levels.all) == value
@pytest.mark.parametrize("value", ["test0", "test1"]) @pytest.mark.parametrize('value', ['test0', 'test1'])
class TestSetAndGetStringHigher: class TestSetAndGetStringHigher:
__test__ = True __test__ = True
"""strip tests, physical and virtual""" """strip tests, physical and virtual"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index, param", 'index, param',
[(data.phys_in, "label"), (data.virt_in, "label")], [(data.phys_in, 'label'), (data.virt_in, 'label')],
) )
def test_it_sets_and_gets_strip_string_params(self, index, param, value): def test_it_sets_and_gets_strip_string_params(self, index, param, value):
setattr(tests.strip[index], param, value) setattr(vban.strip[index], param, value)
assert getattr(tests.strip[index], param) == value assert getattr(vban.strip[index], param) == value
""" bus tests, physical and virtual """ """ bus tests, physical and virtual """
@pytest.mark.parametrize( @pytest.mark.parametrize(
"index, param", 'index, param',
[(data.phys_out, "label"), (data.virt_out, "label")], [(data.phys_out, 'label'), (data.virt_out, 'label')],
) )
def test_it_sets_and_gets_bus_string_params(self, index, param, value): def test_it_sets_and_gets_bus_string_params(self, index, param, value):
setattr(tests.bus[index], param, value) setattr(vban.bus[index], param, value)
assert getattr(tests.bus[index], param) == value assert getattr(vban.bus[index], param) == value

View File

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

View File

@@ -1,3 +1,3 @@
from .factory import request_vbancmd_obj as api from .factory import request_vbancmd_obj as api
__ALL__ = ["api"] __ALL__ = ['api']

View File

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

View File

@@ -1,16 +1,10 @@
import abc
import time import time
from abc import abstractmethod
from enum import IntEnum
from typing import Union from typing import Union
from .enums import NBS, BusModes
from .iremote import IRemote from .iremote import IRemote
from .meta import bus_mode_prop, channel_bool_prop, channel_label_prop from .meta import bus_mode_prop, channel_bool_prop, channel_int_prop, channel_label_prop
BusModes = IntEnum(
"BusModes",
"normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly",
start=0,
)
class Bus(IRemote): class Bus(IRemote):
@@ -20,38 +14,55 @@ class Bus(IRemote):
Defines concrete implementation for bus Defines concrete implementation for bus
""" """
@abstractmethod @abc.abstractmethod
def __str__(self): def __str__(self):
pass pass
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}]" return f'bus[{self.index}]'
@property @property
def gain(self) -> float: def gain(self) -> float:
def fget(): val = self.getter('gain')
val = self.public_packet.busgain[self.index] if val:
if val < 10000: return round(val, 2)
return -val
elif val == ((1 << 16) - 1):
return 0
else: else:
return ((1 << 16) - 1) - val return self.public_packets[NBS.zero].busgain[self.index]
val = self.getter("gain")
if val is None:
val = fget() * 0.01
return round(val, 1)
@gain.setter @gain.setter
def gain(self, val: float): def gain(self, val: float):
self.setter("gain", val) self.setter('gain', val)
def fadeto(self, target: float, time_: int):
self.setter('FadeTo', f'({target}, {time_})')
time.sleep(self._remote.DELAY)
def fadeby(self, change: float, time_: int):
self.setter('FadeBy', f'({change}, {time_})')
time.sleep(self._remote.DELAY)
class BusEQ(IRemote):
@classmethod
def make(cls, remote, index):
BUSEQ_cls = type(
f'BusEQ{remote.kind}',
(cls,),
{
**{param: channel_bool_prop(param) for param in ['on', 'ab']},
},
)
return BUSEQ_cls(remote, index)
@property
def identifier(self) -> str:
return f'bus[{self.index}].eq'
class PhysicalBus(Bus): class PhysicalBus(Bus):
def __str__(self): def __str__(self):
return f"{type(self).__name__}{self.index}" return f'{type(self).__name__}{self.index}'
@property @property
def device(self) -> str: def device(self) -> str:
@@ -64,7 +75,7 @@ class PhysicalBus(Bus):
class VirtualBus(Bus): class VirtualBus(Bus):
def __str__(self): def __str__(self):
return f"{type(self).__name__}{self.index}" return f'{type(self).__name__}{self.index}'
class BusLevel(IRemote): class BusLevel(IRemote):
@@ -79,14 +90,13 @@ class BusLevel(IRemote):
def getter(self): def getter(self):
"""Returns a tuple of level values for the channel.""" """Returns a tuple of level values for the channel."""
return tuple( if not self._remote.stopped() and self._remote.event.ldirty:
round(-i * 0.01, 1) return self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
for i in self._remote.cache["bus_level"][self.range[0] : self.range[-1]] return self.public_packets[NBS.zero].levels.bus[self.range[0] : self.range[-1]]
)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}]" return f'bus[{self.index}]'
@property @property
def all(self) -> tuple: def all(self) -> tuple:
@@ -107,37 +117,51 @@ class BusLevel(IRemote):
def _make_bus_mode_mixin(): def _make_bus_mode_mixin():
"""Creates a mixin of Bus Modes.""" """Creates a mixin of Bus Modes."""
mode_names = [
'normal',
'amix',
'repeat',
'bmix',
'composite',
'tvmix',
'upmix21',
'upmix41',
'upmix61',
'centeronly',
'lfeonly',
'rearonly',
]
def identifier(self) -> str: def identifier(self) -> str:
return f"Bus[{self.index}].mode" return f'bus[{self.index}].mode'
def get(self): def get(self):
time.sleep(0.01) """Get current bus mode using ChannelState for clean bit extraction."""
for i, val in enumerate( mode_cache_items = [
[ (k, v)
self.amix, for k, v in self._remote.cache.items()
self.bmix, if k.startswith(f'{self.identifier}.') and v == 1
self.repeat,
self.composite,
self.tvmix,
self.upmix21,
self.upmix41,
self.upmix61,
self.centeronly,
self.lfeonly,
self.rearonly,
] ]
):
if val: if mode_cache_items:
return BusModes(i + 1).name latest_cached = mode_cache_items[-1][0]
return "normal" 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( return type(
"BusModeMixin", 'BusModeMixin',
(IRemote,), (IRemote,),
{ {
"identifier": property(identifier), 'identifier': property(identifier),
**{mode.name: bus_mode_prop(mode.name) for mode in BusModes}, **{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
"get": get, 'get': get,
}, },
) )
@@ -151,15 +175,15 @@ def bus_factory(phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
BUS_cls = PhysicalBus if phys_bus else VirtualBus BUS_cls = PhysicalBus if phys_bus else VirtualBus
BUSMODEMIXIN_cls = _make_bus_mode_mixin() BUSMODEMIXIN_cls = _make_bus_mode_mixin()
return type( return type(
f"{BUS_cls.__name__}{remote.kind}", f'{BUS_cls.__name__}{remote.kind}',
(BUS_cls,), (BUS_cls,),
{ {
"levels": BusLevel(remote, i), 'eq': BusEQ.make(remote, i),
"mode": BUSMODEMIXIN_cls(remote, i), 'levels': BusLevel(remote, i),
**{param: channel_bool_prop(param) for param in ["mute", "mono"]}, 'mode': BUSMODEMIXIN_cls(remote, i),
"eq": channel_bool_prop("eq.On"), **{param: channel_bool_prop(param) for param in ('mute',)},
"eq_ab": channel_bool_prop("eq.ab"), **{param: channel_int_prop(param) for param in ('mono',)},
"label": channel_label_prop(), 'label': channel_label_prop(),
}, },
)(remote, i) )(remote, i)

View File

@@ -1,6 +1,5 @@
from .error import VMCMDErrors
from .iremote import IRemote from .iremote import IRemote
from .meta import action_prop from .meta import action_fn
class Command(IRemote): class Command(IRemote):
@@ -18,31 +17,30 @@ class Command(IRemote):
Returns a Command class of a kind. Returns a Command class of a kind.
""" """
CMD_cls = type( CMD_cls = type(
f"Command{remote.kind}", f'Command{remote.kind}',
(cls,), (cls,),
{ {
**{ **{
param: action_prop(param) param: action_fn(param) for param in ['show', 'shutdown', 'restart']
for param in ["show", "shutdown", "restart"]
}, },
"hide": action_prop("show", val=0), 'hide': action_fn('show', val=0),
}, },
) )
return CMD_cls(remote) return CMD_cls(remote)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return "Command" return 'command'
def set_showvbanchat(self, val: bool): def set_showvbanchat(self, val: bool):
self.setter("DialogShow.VBANCHAT", 1 if val else 0) self.setter('DialogShow.VBANCHAT', 1 if val else 0)
showvbanchat = property(fset=set_showvbanchat) showvbanchat = property(fset=set_showvbanchat)
def set_lock(self, val: bool): def set_lock(self, val: bool):
self.setter("lock", 1 if val else 0) self.setter('lock', 1 if val else 0)
lock = property(fset=set_lock) lock = property(fset=set_lock)
def reset(self): def reset(self):
self._remote.apply_config("reset") self._remote.apply_config('reset')

View File

@@ -1,10 +1,18 @@
import itertools import itertools
import logging
from pathlib import Path from pathlib import Path
import tomllib from .error import VBANCMDError
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
from .kinds import request_kind_map as kindmap from .kinds import request_kind_map as kindmap
logger = logging.getLogger(__name__)
class TOMLStrBuilder: class TOMLStrBuilder:
"""builds a config profile, as a string, for the toml parser""" """builds a config profile, as a string, for the toml parser"""
@@ -12,66 +20,73 @@ class TOMLStrBuilder:
def __init__(self, kind): def __init__(self, kind):
self.kind = kind self.kind = kind
self.higher = itertools.chain( self.higher = itertools.chain(
[f"strip-{i}" for i in range(kind.num_strip)], [f'strip-{i}' for i in range(kind.num_strip)],
[f"bus-{i}" for i in range(kind.num_bus)], [f'bus-{i}' for i in range(kind.num_bus)],
) )
def init_config(self, profile=None): def init_config(self, profile=None):
self.virt_strip_params = ( self.virt_strip_params = (
[ [
"mute = false", 'mute = false',
"mono = false", 'mono = false',
"solo = false", 'solo = false',
"gain = 0.0", 'gain = 0.0',
] ]
+ [f"A{i} = false" for i in range(1, self.kind.phys_out + 1)] + [f'A{i} = false' for i in range(1, self.kind.phys_out + 1)]
+ [f"B{i} = false" for i in range(1, self.kind.virt_out + 1)] + [f'B{i} = false' for i in range(1, self.kind.virt_out + 1)]
) )
self.phys_strip_params = self.virt_strip_params + [ self.phys_strip_params = self.virt_strip_params + [
"comp = 0.0", 'comp.knob = 0.0',
"gate = 0.0", 'gate.knob = 0.0',
'denoiser.knob = 0.0',
'eq.on = false',
]
self.bus_float = ['gain = 0.0']
self.bus_params = [
'mono = false',
'eq.on = false',
'mute = false',
'gain = 0.0',
] ]
self.bus_bool = ["mono = false", "eq = false", "mute = false"]
if profile == "reset": if profile == 'reset':
self.reset_config() self.reset_config()
def reset_config(self): def reset_config(self):
self.phys_strip_params = list( self.phys_strip_params = list(
map(lambda x: x.replace("B1 = false", "B1 = true"), self.phys_strip_params) map(lambda x: x.replace('B1 = false', 'B1 = true'), self.phys_strip_params)
) )
self.virt_strip_params = list( self.virt_strip_params = list(
map(lambda x: x.replace("A1 = false", "A1 = true"), self.virt_strip_params) map(lambda x: x.replace('A1 = false', 'A1 = true'), self.virt_strip_params)
) )
def build(self, profile="reset"): def build(self, profile='reset'):
self.init_config(profile) self.init_config(profile)
toml_str = str() toml_str = str()
for eachclass in self.higher: for eachclass in self.higher:
toml_str += f"[{eachclass}]\n" toml_str += f'[{eachclass}]\n'
toml_str = self.join(eachclass, toml_str) toml_str = self.join(eachclass, toml_str)
return toml_str return toml_str
def join(self, eachclass, toml_str): def join(self, eachclass, toml_str):
kls, index = eachclass.split("-") kls, index = eachclass.split('-')
match kls: match kls:
case "strip": case 'strip':
toml_str += ("\n").join( toml_str += ('\n').join(
self.phys_strip_params self.phys_strip_params
if int(index) < self.kind.phys_in if int(index) < self.kind.phys_in
else self.virt_strip_params else self.virt_strip_params
) )
case "bus": case 'bus':
toml_str += ("\n").join(self.bus_bool) toml_str += ('\n').join(self.bus_params)
case _: case _:
pass pass
return toml_str + "\n" return toml_str + '\n'
class TOMLDataExtractor: class TOMLDataExtractor:
def __init__(self, file): def __init__(self, file):
self._data = dict() with open(file, 'rb') as f:
with open(file, "rb") as f:
self._data = tomllib.load(f) self._data = tomllib.load(f)
@property @property
@@ -89,10 +104,10 @@ def dataextraction_factory(file):
this opens the possibility for other parsers to be added this opens the possibility for other parsers to be added
""" """
if file.suffix == ".toml": if file.suffix == '.toml':
extractor = TOMLDataExtractor extractor = TOMLDataExtractor
else: else:
raise ValueError("Cannot extract data from {}".format(file)) raise ValueError('Cannot extract data from {}'.format(file))
return extractor(file) return extractor(file)
@@ -118,6 +133,7 @@ class Loader(metaclass=SingletonType):
def __init__(self, kind): def __init__(self, kind):
self._kind = kind self._kind = kind
self.logger = logger.getChild(self.__class__.__name__)
self._configs = dict() self._configs = dict()
self.defaults(kind) self.defaults(kind)
self.parser = None self.parser = None
@@ -125,18 +141,25 @@ class Loader(metaclass=SingletonType):
def defaults(self, kind): def defaults(self, kind):
self.builder = TOMLStrBuilder(kind) self.builder = TOMLStrBuilder(kind)
toml_str = self.builder.build() toml_str = self.builder.build()
self.register("reset", tomllib.loads(toml_str)) self.register('reset', tomllib.loads(toml_str))
def parse(self, identifier, data): def parse(self, identifier, data):
if identifier in self._configs: if identifier in self._configs:
print(f"config file with name {identifier} already in memory, skipping..") self.logger.info(
return False f'config file with name {identifier} already in memory, skipping..'
)
return
try:
self.parser = dataextraction_factory(data) self.parser = dataextraction_factory(data)
except tomllib.TOMLDecodeError as e:
ERR_MSG = (str(e), f'When attempting to load {identifier}.toml')
self.logger.error(f'{type(e).__name__}: {" ".join(ERR_MSG)}')
return
return True return True
def register(self, identifier, data=None): def register(self, identifier, data=None):
self._configs[identifier] = data if data else self.parser.data self._configs[identifier] = data if data else self.parser.data
print(f"config {self.name}/{identifier} loaded into memory") self.logger.info(f'config {self.name}/{identifier} loaded into memory')
def deregister(self): def deregister(self):
self._configs.clear() self._configs.clear()
@@ -159,17 +182,18 @@ def loader(kind):
returns configs loaded into memory returns configs loaded into memory
""" """
logger_loader = logger.getChild('loader')
loader = Loader(kind) loader = Loader(kind)
for path in ( for path in (
Path.cwd() / "configs" / kind.name, Path.cwd() / 'configs' / kind.name,
Path(__file__).parent / "configs" / kind.name, Path.home() / '.config' / 'vban-cmd' / kind.name,
Path.home() / "Documents/Voicemeeter" / "configs" / kind.name, Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / kind.name,
): ):
if path.is_dir(): if path.is_dir():
print(f"Checking [{path}] for TOML config files:") logger_loader.info(f'Checking [{path}] for TOML config files:')
for file in path.glob("*.toml"): for file in path.glob('*.toml'):
identifier = file.with_suffix("").stem identifier = file.with_suffix('').stem
if loader.parse(identifier, file): if loader.parse(identifier, file):
loader.register(identifier) loader.register(identifier)
return loader.configs return loader.configs
@@ -183,6 +207,6 @@ def request_config(kind_id: str):
""" """
try: try:
configs = loader(kindmap(kind_id)) configs = loader(kindmap(kind_id))
except KeyError as e: except KeyError:
print(f"Unknown Voicemeeter kind '{kind_id}'") raise VBANCMDError(f'Unknown Voicemeeter kind {kind_id}')
return configs return configs

20
vban_cmd/enums.py Normal file
View 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,
)

View File

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

56
vban_cmd/event.py Normal file
View File

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

View File

@@ -1,15 +1,21 @@
from abc import abstractmethod import abc
import logging
from enum import IntEnum from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import Iterable, NoReturn, Self from typing import Iterable
from .base import VbanCmd
from .bus import request_bus_obj as bus from .bus import request_bus_obj as bus
from .command import Command from .command import Command
from .config import request_config as configs from .config import request_config as configs
from .error import VBANCMDError
from .kinds import KindMapClass from .kinds import KindMapClass
from .kinds import request_kind_map as kindmap from .kinds import request_kind_map as kindmap
from .macrobutton import MacroButton
from .strip import request_strip_obj as strip from .strip import request_strip_obj as strip
from .vban import request_vban_obj as vban
from .vbancmd import VbanCmd
logger = logging.getLogger(__name__)
class FactoryBuilder: class FactoryBuilder:
@@ -19,58 +25,73 @@ class FactoryBuilder:
Separates construction from representation. Separates construction from representation.
""" """
BuilderProgress = IntEnum("BuilderProgress", "strip bus command", start=0) BuilderProgress = IntEnum(
'BuilderProgress', 'strip bus command macrobutton vban', start=0
)
def __init__(self, factory, kind: KindMapClass): def __init__(self, factory, kind: KindMapClass):
self._factory = factory self._factory = factory
self.kind = kind self.kind = kind
self._info = ( self._info = (
f"Finished building strips for {self._factory}", f'Finished building strips for {self._factory}',
f"Finished building buses for {self._factory}", f'Finished building buses for {self._factory}',
f"Finished building commands for {self._factory}", f'Finished building commands for {self._factory}',
f'Finished building macrobuttons for {self._factory}',
f'Finished building vban in/out streams for {self._factory}',
) )
self.logger = logger.getChild(self.__class__.__name__)
def _pinfo(self, name: str) -> NoReturn: def _pinfo(self, name: str) -> None:
"""prints progress status for each step""" """prints progress status for each step"""
name = name.split("_")[1] name = name.split('_')[1]
print(self._info[int(getattr(self.BuilderProgress, name))]) self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
def make_strip(self) -> Self: def make_strip(self):
self._factory.strip = tuple( self._factory.strip = tuple(
strip(i < self.kind.phys_in, self._factory, i) strip(i < self.kind.phys_in, self._factory, i)
for i in range(self.kind.num_strip) for i in range(self.kind.num_strip)
) )
return self return self
def make_bus(self) -> Self: def make_bus(self):
self._factory.bus = tuple( self._factory.bus = tuple(
bus(i < self.kind.phys_out, self._factory, i) bus(i < self.kind.phys_out, self._factory, i)
for i in range(self.kind.num_bus) for i in range(self.kind.num_bus)
) )
return self return self
def make_command(self) -> Self: def make_command(self):
self._factory.command = Command.make(self._factory) self._factory.command = Command.make(self._factory)
return self return self
def make_macrobutton(self):
self._factory.button = tuple(MacroButton(self._factory, i) for i in range(80))
return self
def make_vban(self):
self._factory.vban = vban(self._factory)
return self
class FactoryBase(VbanCmd): class FactoryBase(VbanCmd):
"""Base class for factories, subclasses VbanCmd.""" """Base class for factories, subclasses VbanCmd."""
def __init__(self, kind_id: str, **kwargs): def __init__(self, kind_id: str, **kwargs):
defaultsubs = {"pdirty": True, "ldirty": False}
if "subs" in kwargs:
defaultsubs = defaultsubs | kwargs.pop("subs")
defaultkwargs = { defaultkwargs = {
"ip": None, 'ip': 'localhost',
"port": 6980, 'port': 6980,
"streamname": "Command1", 'streamname': 'Command1',
"bps": 0, 'bps': 256000,
"channel": 0, 'channel': 0,
"ratelimit": 0.01, 'ratelimit': 0.01,
"sync": False, 'timeout': 5,
"subs": defaultsubs, 'disable_rt_listeners': False,
'sync': False,
'pdirty': False,
'ldirty': False,
} }
if 'subs' in kwargs:
defaultkwargs |= kwargs.pop('subs') # for backwards compatibility
kwargs = defaultkwargs | kwargs kwargs = defaultkwargs | kwargs
self.kind = kindmap(kind_id) self.kind = kindmap(kind_id)
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -79,14 +100,22 @@ class FactoryBase(VbanCmd):
self.builder.make_strip, self.builder.make_strip,
self.builder.make_bus, self.builder.make_bus,
self.builder.make_command, self.builder.make_command,
self.builder.make_macrobutton,
self.builder.make_vban,
) )
self._configs = None self._configs = None
def __str__(self) -> str: def __str__(self) -> str:
return f"Voicemeeter {self.kind}" return f'Voicemeeter {self.kind}'
def __repr__(self):
return (
type(self).__name__
+ f"({self.kind}, ip='{self.ip}', port={self.port}, streamname='{self.streamname}')"
)
@property @property
@abstractmethod @abc.abstractmethod
def steps(self): def steps(self):
pass pass
@@ -169,15 +198,21 @@ def vbancmd_factory(kind_id: str, **kwargs) -> VbanCmd:
Returns a VbanCmd class of a kind Returns a VbanCmd class of a kind
""" """
match kind_id: match kind_id:
case "basic": case 'basic':
_factory = BasicFactory _factory = BasicFactory
case "banana": case 'banana':
_factory = BananaFactory _factory = BananaFactory
case "potato": case 'potato' | 'matrix':
# matrix is a special kind where:
# - we don't need to scale the interface with the builder (in other words kind is arbitrary).
# - we don't ever need to use real-time listeners, so we disable them to avoid confusion
if kind_id == 'matrix':
kwargs['disable_rt_listeners'] = True
kind_id = 'potato'
_factory = PotatoFactory _factory = PotatoFactory
case _: case _:
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'") raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
return type(f"VbanCmd{kind_id.capitalize()}", (_factory,), {})(kind_id, **kwargs) return type(f'VbanCmd{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs)
def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd: def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
@@ -186,9 +221,12 @@ def request_vbancmd_obj(kind_id: str, **kwargs) -> VbanCmd:
Returns a reference to a VbanCmd class of a kind Returns a reference to a VbanCmd class of a kind
""" """
logger_entry = logger.getChild('factory.request_vbancmd_obj')
VBANCMD_obj = None VBANCMD_obj = None
try: try:
VBANCMD_obj = vbancmd_factory(kind_id, **kwargs) VBANCMD_obj = vbancmd_factory(kind_id, **kwargs)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
raise SystemExit(e) logger_entry.exception(f'{type(e).__name__}: {e}')
raise VBANCMDError(str(e)) from e
return VBANCMD_obj return VBANCMD_obj

View File

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

View File

@@ -1,12 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, unique
from .enums import KindId
@unique from .error import VBANCMDError
class KindId(Enum):
BASIC = 1
BANANA = 2
POTATO = 3
class SingletonType(type): class SingletonType(type):
@@ -20,12 +15,15 @@ class SingletonType(type):
return cls._instances[cls] return cls._instances[cls]
@dataclass @dataclass(frozen=True)
class KindMapClass(metaclass=SingletonType): class KindMapClass(metaclass=SingletonType):
name: str name: str
ins: tuple ins: tuple
outs: tuple outs: tuple
vban: tuple vban: tuple
strip_channels: int
bus_channels: int
cells: int
@property @property
def phys_in(self): def phys_in(self):
@@ -51,44 +49,61 @@ class KindMapClass(metaclass=SingletonType):
def num_bus(self): def num_bus(self):
return sum(self.outs) return sum(self.outs)
@property
def num_strip_levels(self) -> int:
return 2 * self.phys_in + 8 * self.virt_in
@property
def num_bus_levels(self) -> int:
return 8 * (self.phys_out + self.virt_out)
def __str__(self) -> str: def __str__(self) -> str:
return self.name.capitalize() return self.name.capitalize()
@dataclass @dataclass(frozen=True)
class BasicMap(KindMapClass): class BasicMap(KindMapClass):
name: str name: str
ins: tuple = (2, 1) ins: tuple = (2, 1)
outs: tuple = (1, 1) outs: tuple = (1, 1)
vban: tuple = (4, 4) vban: tuple = (4, 4, 1, 1)
strip_channels: int = 0
bus_channels: int = 0
cells: int = 0
@dataclass @dataclass(frozen=True)
class BananaMap(KindMapClass): class BananaMap(KindMapClass):
name: str name: str
ins: tuple = (3, 2) ins: tuple = (3, 2)
outs: tuple = (3, 2) outs: tuple = (3, 2)
vban: tuple = (8, 8) vban: tuple = (8, 8, 1, 1)
strip_channels: int = 0
bus_channels: int = 8
cells: int = 6
@dataclass @dataclass(frozen=True)
class PotatoMap(KindMapClass): class PotatoMap(KindMapClass):
name: str name: str
ins: tuple = (5, 3) ins: tuple = (5, 3)
outs: tuple = (5, 3) outs: tuple = (5, 3)
vban: tuple = (8, 8) vban: tuple = (8, 8, 1, 1)
strip_channels: int = 2
bus_channels: int = 8
cells: int = 6
def kind_factory(kind_id): def kind_factory(kind_id):
match kind_id: match kind_id:
case "basic": case 'basic':
_kind_map = BasicMap _kind_map = BasicMap
case "banana": case 'banana':
_kind_map = BananaMap _kind_map = BananaMap
case "potato": case 'potato':
_kind_map = PotatoMap _kind_map = PotatoMap
case _: case _:
raise ValueError(f"Unknown Voicemeeter kind {kind_id}") raise ValueError(f'Unknown Voicemeeter kind {kind_id}')
return _kind_map(name=kind_id) return _kind_map(name=kind_id)
@@ -97,8 +112,8 @@ def request_kind_map(kind_id):
try: try:
KIND_obj = kind_factory(kind_id) KIND_obj = kind_factory(kind_id)
except ValueError as e: except ValueError as e:
print(e) raise VBANCMDError(str(e)) from e
return KIND_obj return KIND_obj
kinds_all = list(request_kind_map(kind_id.name.lower()) for kind_id in KindId) all = list(request_kind_map(kind_id.name.lower()) for kind_id in KindId)

36
vban_cmd/macrobutton.py Normal file
View File

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

View File

@@ -1,7 +1,7 @@
from functools import partial from functools import partial
from .error import VMCMDErrors from .enums import NBS, BusModes
from .util import cache_bool, cache_string from .util import cache_bool, cache_float, cache_int, cache_string
def channel_bool_prop(param): def channel_bool_prop(param):
@@ -9,17 +9,25 @@ def channel_bool_prop(param):
@partial(cache_bool, param=param) @partial(cache_bool, param=param)
def fget(self): def fget(self):
return ( cmd = self._cmd(param)
not int.from_bytes( self.logger.debug(f'getter: {cmd}')
getattr(
self.public_packet, states = self.public_packets[NBS.zero].states
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}state", channel_states = (
)[self.index], states.strip if 'strip' in type(self).__name__.lower() else states.bus
"little",
)
& getattr(self._modes, f'_{param.replace(".", "_").lower()}')
== 0
) )
channel_state = channel_states[self.index]
if param.lower() == 'mute':
return channel_state.mute
elif param.lower() == 'solo':
return channel_state.solo
elif param.lower() == 'mono':
return channel_state.mono
elif param.lower() == 'mc':
return channel_state.mc
else:
return channel_state.get_mode(getattr(self._modes, f'_{param.lower()}'))
def fset(self, val): def fset(self, val):
self.setter(param, 1 if val else 0) self.setter(param, 1 if val else 0)
@@ -27,18 +35,46 @@ def channel_bool_prop(param):
return property(fget, fset) return property(fget, fset)
def channel_int_prop(param):
"""meta function for channel integer parameters"""
@partial(cache_int, param=param)
def fget(self):
cmd = self._cmd(param)
self.logger.debug(f'getter: {cmd}')
states = self.public_packets[NBS.zero].states
channel_states = (
states.strip if 'strip' in type(self).__name__.lower() else states.bus
)
channel_state = channel_states[self.index]
# Special case: bus mono is an integer (0-2) encoded using bits 2 and 9
if param.lower() == 'mono' and 'bus' in type(self).__name__.lower():
bit_2 = (channel_state._state >> 2) & 1
bit_9 = (channel_state._state >> 9) & 1
return (bit_9 << 1) | bit_2
else:
return channel_state.get_mode_int(getattr(self._modes, f'_{param.lower()}'))
def fset(self, val):
self.setter(param, val)
return property(fget, fset)
def channel_label_prop(): def channel_label_prop():
"""meta function for channel label parameters""" """meta function for channel label parameters"""
@partial(cache_string, param="label") @partial(cache_string, param='label')
def fget(self) -> str: def fget(self) -> str:
return getattr( if 'strip' in type(self).__name__.lower():
self.public_packet, return self.public_packets[NBS.zero].labels.strip[self.index]
f"{'strip' if 'strip' in type(self).__name__.lower() else 'bus'}labels", else:
)[self.index] return self.public_packets[NBS.zero].labels.bus[self.index]
def fset(self, val: str): def fset(self, val: str):
self.setter("label", str(val)) self.setter('label', f'"{val}"')
return property(fget, fset) return property(fget, fset)
@@ -48,11 +84,12 @@ def strip_output_prop(param):
@partial(cache_bool, param=param) @partial(cache_bool, param=param)
def fget(self): def fget(self):
return ( cmd = self._cmd(param)
not int.from_bytes(self.public_packet.stripstate[self.index], "little") self.logger.debug(f'getter: {cmd}')
& getattr(self._modes, f"_bus{param.lower()}")
== 0 strip_state = self.public_packets[NBS.zero].states.strip[self.index]
)
return strip_state.get_mode(getattr(self._modes, f'_bus{param.lower()}'))
def fset(self, val): def fset(self, val):
self.setter(param, 1 if val else 0) self.setter(param, 1 if val else 0)
@@ -65,26 +102,17 @@ def bus_mode_prop(param):
@partial(cache_bool, param=param) @partial(cache_bool, param=param)
def fget(self): def fget(self):
modelist = { cmd = self._cmd(param)
"amix": (1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1), self.logger.debug(f'getter: {cmd}')
"repeat": (0, 2, 2, 0, 0, 2, 2, 0, 0, 2, 2),
"bmix": (1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3), bus_state = self.public_packets[NBS.zero].states.bus[self.index]
"composite": (0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0),
"tvmix": (1, 0, 1, 4, 5, 4, 5, 0, 1, 0, 1), # Extract current bus mode from bits 4-7
"upmix21": (0, 2, 2, 4, 4, 6, 6, 0, 0, 2, 2), current_mode = (bus_state._state & 0x000000F0) >> 4
"upmix41": (1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3),
"upmix61": (0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8), expected_mode = getattr(BusModes, param.lower())
"centeronly": (1, 0, 1, 0, 1, 0, 1, 8, 9, 8, 9),
"lfeonly": (0, 2, 2, 0, 0, 2, 2, 8, 8, 10, 10), return current_mode == expected_mode
"rearonly": (1, 2, 3, 0, 1, 2, 3, 8, 9, 10, 11),
}
vals = (
int.from_bytes(self.public_packet.busstate[self.index], "little") & val
for val in self._modes.modevals
)
if param == "normal":
return not any(vals)
return tuple(round(val / 16) for val in vals) == modelist[param]
def fset(self, val): def fset(self, val):
self.setter(param, 1 if val else 0) self.setter(param, 1 if val else 0)
@@ -92,10 +120,68 @@ def bus_mode_prop(param):
return property(fget, fset) return property(fget, fset)
def action_prop(param, val=1): def action_fn(param, val=1):
"""A param that performs an action""" """A function that performs an action"""
def fdo(self): def fdo(self):
self.setter(param, val) self.setter(param, val)
return fdo return fdo
def xy_prop(param):
"""meta function for XY pad parameters"""
@partial(cache_float, param=param)
def fget(self):
cmd = self._cmd(param)
self.logger.debug(f'getter: {cmd}')
if self.public_packets[NBS.one] is None:
return 0.0
positions = self.public_packets[NBS.one].strips[self.index].positions
match param:
case 'pan_x':
return positions.pan_x
case 'pan_y':
return positions.pan_y
case 'color_x':
return positions.color_x
case 'color_y':
return positions.color_y
case 'fx1':
return positions.fx1
case 'fx2':
return positions.fx2
def fset(self, val):
self.setter(param, val)
return property(fget, fset)
def send_prop(param):
"""meta function for send parameters"""
@partial(cache_float, param=param)
def fget(self):
cmd = self._cmd(param)
self.logger.debug(f'getter: {cmd}')
if self.public_packets[NBS.one] is None:
return 0.0
sends = self.public_packets[NBS.one].strips[self.index].sends
match param:
case 'reverb':
return sends.reverb
case 'delay':
return sends.delay
case 'fx1':
return sends.fx1
case 'fx2':
return sends.fx2
def fset(self, val):
self.setter(param, val)
return property(fget, fset)

View File

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

View File

@@ -1,288 +0,0 @@
from dataclasses import dataclass
from typing import Generator
VBAN_SERVICE_RTPACKETREGISTER = 32
VBAN_SERVICE_RTPACKET = 33
MAX_PACKET_SIZE = 1436
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16 + 4
@dataclass
class VBAN_VMRT_Packet_Data:
"""Represents the structure of a VMRT data packet"""
_voicemeeterType: bytes
_reserved: bytes
_buffersize: bytes
_voicemeeterVersion: bytes
_optionBits: bytes
_samplerate: bytes
_inputLeveldB100: bytes
_outputLeveldB100: bytes
_TransportBit: bytes
_stripState: bytes
_busState: bytes
_stripGaindB100Layer1: bytes
_stripGaindB100Layer2: bytes
_stripGaindB100Layer3: bytes
_stripGaindB100Layer4: bytes
_stripGaindB100Layer5: bytes
_stripGaindB100Layer6: bytes
_stripGaindB100Layer7: bytes
_stripGaindB100Layer8: bytes
_busGaindB100: bytes
_stripLabelUTF8c60: bytes
_busLabelUTF8c60: bytes
def pdirty(self, other):
"""True iff any defined parameter has changed"""
return not (
self._stripState == other._stripState
and self._busState == other._busState
and self._stripLabelUTF8c60 == other._stripLabelUTF8c60
and self._busLabelUTF8c60 == other._busLabelUTF8c60
and self._stripGaindB100Layer1 == other._stripGaindB100Layer1
and self._stripGaindB100Layer2 == other._stripGaindB100Layer2
and self._stripGaindB100Layer3 == other._stripGaindB100Layer3
and self._stripGaindB100Layer4 == other._stripGaindB100Layer4
and self._stripGaindB100Layer5 == other._stripGaindB100Layer5
and self._stripGaindB100Layer6 == other._stripGaindB100Layer6
and self._stripGaindB100Layer7 == other._stripGaindB100Layer7
and self._stripGaindB100Layer8 == other._stripGaindB100Layer8
and self._busGaindB100 == other._busGaindB100
)
@property
def voicemeetertype(self) -> str:
"""returns voicemeeter type as a string"""
type_ = ("basic", "banana", "potato")
return type_[int.from_bytes(self._voicemeeterType, "little") - 1]
@property
def voicemeeterversion(self) -> tuple:
"""returns voicemeeter version as a tuple"""
return tuple(
reversed(
tuple(
int.from_bytes(self._voicemeeterVersion[i : i + 1], "little")
for i in range(4)
)
)
)
@property
def samplerate(self) -> int:
"""returns samplerate as an int"""
return int.from_bytes(self._samplerate, "little")
@property
def inputlevels(self) -> Generator[float, None, None]:
"""returns the entire level array across all inputs"""
for i in range(0, 68, 2):
val = ((1 << 16) - 1) - int.from_bytes(
self._inputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
@property
def outputlevels(self) -> Generator[float, None, None]:
"""returns the entire level array across all outputs"""
for i in range(0, 128, 2):
val = ((1 << 16) - 1) - int.from_bytes(
self._outputLeveldB100[i : i + 2], "little"
)
if val != ((1 << 16) - 1):
yield val
@property
def stripstate(self) -> tuple:
"""returns tuple of strip states accessable through bit modes"""
return tuple(self._stripState[i : i + 4] for i in range(0, 32, 4))
@property
def busstate(self) -> tuple:
"""returns tuple of bus states accessable through bit modes"""
return tuple(self._busState[i : i + 4] for i in range(0, 32, 4))
"""
these functions return an array of gainlayers[i] across all strips
ie stripgainlayer1 = [strip[0].gainlayer[0], strip[1].gainlayer[0], strip[2].gainlayer[0]...]
"""
@property
def stripgainlayer1(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer1[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer2(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer2[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer3(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer3[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer4(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer4[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer5(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer5[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer6(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer6[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer7(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer7[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def stripgainlayer8(self) -> tuple:
return tuple(
((1 << 16) - 1)
- int.from_bytes(self._stripGaindB100Layer8[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def busgain(self) -> tuple:
"""returns tuple of bus gains"""
return tuple(
((1 << 16) - 1) - int.from_bytes(self._busGaindB100[i : i + 2], "little")
for i in range(0, 16, 2)
)
@property
def striplabels(self) -> tuple:
"""returns tuple of strip labels"""
return tuple(
self._stripLabelUTF8c60[i : i + 60].decode().split("\x00")[0]
for i in range(0, 480, 60)
)
@property
def buslabels(self) -> tuple:
"""returns tuple of bus labels"""
return tuple(
self._busLabelUTF8c60[i : i + 60].decode().split("\x00")[0]
for i in range(0, 480, 60)
)
@dataclass
class VBAN_VMRT_Packet_Header:
"""Represents a RESPONSE RT PACKET header"""
name = "Voicemeeter-RTP"
vban: bytes = "VBAN".encode()
format_sr: bytes = (0x60).to_bytes(1, "little")
format_nbs: bytes = (0).to_bytes(1, "little")
format_nbc: bytes = (VBAN_SERVICE_RTPACKET).to_bytes(1, "little")
format_bit: bytes = (0).to_bytes(1, "little")
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
@property
def header(self):
header = self.vban
header += self.format_sr
header += self.format_nbs
header += self.format_nbc
header += self.format_bit
header += self.streamname
assert len(header) == HEADER_SIZE - 4, f"Header expected {HEADER_SIZE-4} bytes"
return header
@dataclass
class TextRequestHeader:
"""Represents a REQUEST RT PACKET header"""
name: str
bps_index: int
channel: int
vban: bytes = "VBAN".encode()
nbs: bytes = (0).to_bytes(1, "little")
bit: bytes = (0x10).to_bytes(1, "little")
framecounter: bytes = (0).to_bytes(4, "little")
@property
def sr(self):
return (0x40 + self.bps_index).to_bytes(1, "little")
@property
def nbc(self):
return (self.channel).to_bytes(1, "little")
@property
def streamname(self):
return self.name.encode() + bytes(16 - len(self.name))
@property
def header(self):
header = self.vban
header += self.sr
header += self.nbs
header += self.nbc
header += self.bit
header += self.streamname
header += self.framecounter
assert len(header) == HEADER_SIZE, f"Header expected {HEADER_SIZE} bytes"
return header
@dataclass
class RegisterRTHeader:
"""Represents a REGISTER RT PACKET header"""
name = "Register RTP"
timeout = 15
vban: bytes = "VBAN".encode()
format_sr: bytes = (0x60).to_bytes(1, "little")
format_nbs: bytes = (0).to_bytes(1, "little")
format_nbc: bytes = (VBAN_SERVICE_RTPACKETREGISTER).to_bytes(1, "little")
format_bit: bytes = (timeout & 0x000000FF).to_bytes(1, "little") # timeout
streamname: bytes = name.encode("ascii") + bytes(16 - len(name))
framecounter: bytes = (0).to_bytes(4, "little")
@property
def header(self):
header = self.vban
header += self.format_sr
header += self.format_nbs
header += self.format_nbc
header += self.format_bit
header += self.streamname
header += self.framecounter
assert len(header) == HEADER_SIZE, f"Header expected {HEADER_SIZE} bytes"
return header

266
vban_cmd/packet/headers.py Normal file
View File

@@ -0,0 +1,266 @@
from dataclasses import dataclass
from vban_cmd.enums import NBS
from vban_cmd.kinds import KindMapClass
VBAN_PROTOCOL_TXT = 0x40
VBAN_PROTOCOL_SERVICE = 0x60
VBAN_SERVICE_RTPACKETREGISTER = 32
VBAN_SERVICE_RTPACKET = 33
VBAN_SERVICE_MASK = 0xE0
VBAN_PROTOCOL_MASK = 0xE0
VBAN_SERVICE_REQUESTREPLY = 0x02
VBAN_SERVICE_FNCT_REPLY = 0x02
MAX_PACKET_SIZE = 1436
HEADER_SIZE = 4 + 1 + 1 + 1 + 1 + 16
@dataclass
class VbanPacket:
"""Represents the header of an incoming VBAN data packet"""
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 VbanSubscribeHeader:
"""Represents the header of a 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 VBAN_PROTOCOL_SERVICE.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 VBAN_SERVICE_RTPACKETREGISTER.to_bytes(1, 'little')
@property
def format_bit(self) -> bytes:
return (self.timeout & 0xFF).to_bytes(1, 'little')
@property
def streamname(self) -> bytes:
return self.name.encode('ascii') + bytes(16 - len(self.name))
@classmethod
def to_bytes(cls, nbs: NBS, framecounter: int) -> bytes:
header = cls(nbs=nbs)
data = bytearray()
data.extend(header.vban)
data.extend(header.format_sr)
data.extend(header.format_nbs)
data.extend(header.format_nbc)
data.extend(header.format_bit)
data.extend(header.streamname)
data.extend(framecounter.to_bytes(4, 'little'))
return bytes(data)
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 & VBAN_PROTOCOL_MASK
if protocol != VBAN_PROTOCOL_SERVICE:
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 VbanResponseHeader:
"""Represents the header of a response packet"""
name: str = 'Voicemeeter-RTP'
format_sr: int = VBAN_PROTOCOL_SERVICE
format_nbs: int = 0
format_nbc: int = VBAN_SERVICE_RTPACKET
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'] != VBAN_SERVICE_RTPACKET:
raise ValueError(
f'Not a 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 = VBAN_PROTOCOL_SERVICE
format_nbs: int = VBAN_SERVICE_FNCT_REPLY
format_nbc: int = VBAN_SERVICE_REQUESTREPLY
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'] != VBAN_SERVICE_FNCT_REPLY:
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
@dataclass
class VbanRequestHeader:
"""Represents the header of a 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 (VBAN_PROTOCOL_TXT + self.bps_index).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 (0x10).to_bytes(1, 'little')
@property
def streamname(self) -> bytes:
return self.name.encode() + bytes(16 - len(self.name))
@classmethod
def to_bytes(
cls, name: str, bps_index: int, channel: int, framecounter: int
) -> bytes:
header = cls(
name=name, bps_index=bps_index, channel=channel, framecounter=framecounter
)
data = bytearray()
data.extend(header.vban)
data.extend(header.sr)
data.extend(header.nbs)
data.extend(header.nbc)
data.extend(header.bit)
data.extend(header.streamname)
data.extend(header.framecounter.to_bytes(4, 'little'))
return bytes(data)
@classmethod
def encode_with_payload(
cls, name: str, bps_index: int, channel: int, framecounter: int, payload: str
) -> bytes:
"""Creates the complete packet with header and payload."""
return cls.to_bytes(name, bps_index, channel, framecounter) + payload.encode()

288
vban_cmd/packet/nbs0.py Normal file
View File

@@ -0,0 +1,288 @@
from dataclasses import dataclass
from typing import NamedTuple
from vban_cmd.enums import NBS
from vban_cmd.kinds import KindMapClass
from vban_cmd.util import comp
from .headers import VbanPacket
class Levels(NamedTuple):
strip: tuple[float, ...]
bus: tuple[float, ...]
class ChannelState:
"""Represents the processed state of a single strip or bus channel"""
def __init__(self, state_bytes: bytes):
# Convert 4-byte state to integer once for efficient lookups
self._state = int.from_bytes(state_bytes, 'little')
def get_mode(self, mode_value: int) -> bool:
"""Get boolean state for a specific mode"""
return (self._state & mode_value) != 0
def get_mode_int(self, mode_value: int) -> int:
"""Get integer state for a specific mode"""
return self._state & mode_value
# Common boolean modes
@property
def mute(self) -> bool:
return (self._state & 0x00000001) != 0
@property
def solo(self) -> bool:
return (self._state & 0x00000002) != 0
@property
def mono(self) -> bool:
return (self._state & 0x00000004) != 0
@property
def mc(self) -> bool:
return (self._state & 0x00000008) != 0
# EQ modes
@property
def eq_on(self) -> bool:
return (self._state & 0x00000100) != 0
@property
def eq_ab(self) -> bool:
return (self._state & 0x00000800) != 0
# Bus assignments (strip to bus routing)
@property
def busa1(self) -> bool:
return (self._state & 0x00001000) != 0
@property
def busa2(self) -> bool:
return (self._state & 0x00002000) != 0
@property
def busa3(self) -> bool:
return (self._state & 0x00004000) != 0
@property
def busa4(self) -> bool:
return (self._state & 0x00008000) != 0
@property
def busb1(self) -> bool:
return (self._state & 0x00010000) != 0
@property
def busb2(self) -> bool:
return (self._state & 0x00020000) != 0
@property
def busb3(self) -> bool:
return (self._state & 0x00040000) != 0
class States(NamedTuple):
strip: tuple[ChannelState, ...]
bus: tuple[ChannelState, ...]
class Labels(NamedTuple):
strip: tuple[str, ...]
bus: tuple[str, ...]
@dataclass
class VbanPacketNBS0(VbanPacket):
"""Represents the body of a VBAN data packet with ident:0"""
_inputLeveldB100: bytes
_outputLeveldB100: bytes
_TransportBit: bytes
_stripState: bytes
_busState: bytes
_stripGaindB100Layer1: bytes
_stripGaindB100Layer2: bytes
_stripGaindB100Layer3: bytes
_stripGaindB100Layer4: bytes
_stripGaindB100Layer5: bytes
_stripGaindB100Layer6: bytes
_stripGaindB100Layer7: bytes
_stripGaindB100Layer8: bytes
_busGaindB100: bytes
_stripLabelUTF8c60: bytes
_busLabelUTF8c60: bytes
@classmethod
def from_bytes(cls, nbs: NBS, kind: KindMapClass, data: bytes):
return cls(
nbs=nbs,
_kind=kind,
_voicemeeterType=data[28:29],
_reserved=data[29:30],
_buffersize=data[30:32],
_voicemeeterVersion=data[32:36],
_optionBits=data[36:40],
_samplerate=data[40:44],
_inputLeveldB100=data[44:112],
_outputLeveldB100=data[112:240],
_TransportBit=data[240:244],
_stripState=data[244:276],
_busState=data[276:308],
_stripGaindB100Layer1=data[308:324],
_stripGaindB100Layer2=data[324:340],
_stripGaindB100Layer3=data[340:356],
_stripGaindB100Layer4=data[356:372],
_stripGaindB100Layer5=data[372:388],
_stripGaindB100Layer6=data[388:404],
_stripGaindB100Layer7=data[404:420],
_stripGaindB100Layer8=data[420:436],
_busGaindB100=data[436:452],
_stripLabelUTF8c60=data[452:932],
_busLabelUTF8c60=data[932:1412],
)
def pdirty(self, other) -> bool:
"""True iff any defined parameter has changed"""
self_gains = (
self._stripGaindB100Layer1
+ self._stripGaindB100Layer2
+ self._stripGaindB100Layer3
+ self._stripGaindB100Layer4
+ self._stripGaindB100Layer5
+ self._stripGaindB100Layer6
+ self._stripGaindB100Layer7
+ self._stripGaindB100Layer8
)
other_gains = (
other._stripGaindB100Layer1
+ other._stripGaindB100Layer2
+ other._stripGaindB100Layer3
+ other._stripGaindB100Layer4
+ other._stripGaindB100Layer5
+ other._stripGaindB100Layer6
+ other._stripGaindB100Layer7
+ other._stripGaindB100Layer8
)
return (
self._stripState != other._stripState
or self._busState != other._busState
or self_gains != other_gains
or self._busGaindB100 != other._busGaindB100
or self._stripLabelUTF8c60 != other._stripLabelUTF8c60
or self._busLabelUTF8c60 != other._busLabelUTF8c60
)
def ldirty(self, strip_cache, bus_cache) -> bool:
"""True iff any level has changed, ignoring changes when levels are very quiet"""
self._strip_comp, self._bus_comp = (
tuple(not val for val in comp(strip_cache, self.strip_levels)),
tuple(not val for val in comp(bus_cache, self.bus_levels)),
)
return any(self._strip_comp) or any(self._bus_comp)
@property
def strip_levels(self) -> tuple[float, ...]:
"""Returns strip levels in dB"""
return tuple(
round(
int.from_bytes(self._inputLeveldB100[i : i + 2], 'little', signed=True)
* 0.01,
1,
)
for i in range(0, len(self._inputLeveldB100), 2)
)[: self._kind.num_strip_levels]
@property
def bus_levels(self) -> tuple[float, ...]:
"""Returns bus levels in dB"""
return tuple(
round(
int.from_bytes(self._outputLeveldB100[i : i + 2], 'little', signed=True)
* 0.01,
1,
)
for i in range(0, len(self._outputLeveldB100), 2)
)[: self._kind.num_bus_levels]
@property
def levels(self) -> Levels:
"""Returns strip and bus levels as a namedtuple"""
return Levels(strip=self.strip_levels, bus=self.bus_levels)
@property
def states(self) -> States:
"""returns States object with processed strip and bus channel states"""
return States(
strip=tuple(
ChannelState(self._stripState[i : i + 4]) for i in range(0, 32, 4)
),
bus=tuple(ChannelState(self._busState[i : i + 4]) for i in range(0, 32, 4)),
)
@property
def gainlayers(self) -> tuple:
"""returns tuple of all strip gain layers as tuples"""
return tuple(
tuple(
round(
int.from_bytes(
getattr(self, f'_stripGaindB100Layer{layer}')[i : i + 2],
'little',
signed=True,
)
* 0.01,
2,
)
for i in range(0, 16, 2)
)
for layer in range(1, 9)
)
@property
def busgain(self) -> tuple:
"""returns tuple of bus gains"""
return tuple(
round(
int.from_bytes(self._busGaindB100[i : i + 2], 'little', signed=True)
* 0.01,
2,
)
for i in range(0, 16, 2)
)
@property
def labels(self) -> Labels:
"""returns Labels namedtuple of strip and bus labels"""
def _extract_labels_from_bytes(label_bytes: bytes) -> tuple[str, ...]:
"""Extract null-terminated UTF-8 labels from 60-byte chunks"""
labels = []
for i in range(0, len(label_bytes), 60):
chunk = label_bytes[i : i + 60]
null_pos = chunk.find(b'\x00')
if null_pos == -1:
try:
label = chunk.decode('utf-8', errors='replace').rstrip('\x00')
except UnicodeDecodeError:
label = ''
else:
try:
label = (
chunk[:null_pos].decode('utf-8', errors='replace')
if null_pos > 0
else ''
)
except UnicodeDecodeError:
label = ''
labels.append(label)
return tuple(labels)
return Labels(
strip=_extract_labels_from_bytes(self._stripLabelUTF8c60),
bus=_extract_labels_from_bytes(self._busLabelUTF8c60),
)

357
vban_cmd/packet/nbs1.py Normal file
View File

@@ -0,0 +1,357 @@
import struct
from dataclasses import dataclass
from typing import NamedTuple
from vban_cmd.enums import NBS
from vban_cmd.kinds import KindMapClass
from .headers import VbanPacket
VMPARAMSTRIP_SIZE = 174
class Audibility(NamedTuple):
knob: float
comp: float
gate: float
denoiser: float
class Positions(NamedTuple):
pan_x: float
pan_y: float
color_x: float
color_y: float
fx1: float
fx2: float
class EqGains(NamedTuple):
bass: float
mid: float
treble: float
class ParametricEQSettings(NamedTuple):
on: bool
type: int
gain: float
freq: float
q: float
class Sends(NamedTuple):
reverb: float
delay: float
fx1: float
fx2: float
class CompressorSettings(NamedTuple):
gain_in: float
attack_ms: float
release_ms: float
n_knee: float
ratio: float
threshold: float
c_enabled: bool
makeup: bool
gain_out: float
class GateSettings(NamedTuple):
threshold_in: float
damping_max: float
bp_sidechain: bool
attack_ms: float
hold_ms: float
release_ms: float
class DenoiserSettings(NamedTuple):
threshold: float
class PitchSettings(NamedTuple):
enabled: bool
dry_wet: float
value: float
formant_lo: float
formant_med: float
formant_high: float
@dataclass
class VbanVMParamStrip:
"""Represents the VBAN_VMPARAMSTRIP_PACKET structure"""
_mode: bytes
_dblevel: bytes
_audibility: bytes
_pos3D_x: bytes
_pos3D_y: bytes
_posColor_x: bytes
_posColor_y: bytes
_EQgain1: bytes
_EQgain2: bytes
_EQgain3: bytes
# First channel parametric EQ
_PEQ_eqOn: bytes
_PEQ_eqtype: bytes
_PEQ_eqgain: bytes
_PEQ_eqfreq: bytes
_PEQ_eqq: bytes
_audibility_c: bytes
_audibility_g: bytes
_audibility_d: bytes
_posMod_x: bytes
_posMod_y: bytes
_send_reverb: bytes
_send_delay: bytes
_send_fx1: bytes
_send_fx2: bytes
_dblimit: bytes
_nKaraoke: bytes
_COMP_gain_in: bytes
_COMP_attack_ms: bytes
_COMP_release_ms: bytes
_COMP_n_knee: bytes
_COMP_comprate: bytes
_COMP_threshold: bytes
_COMP_c_enabled: bytes
_COMP_c_auto: bytes
_COMP_gain_out: bytes
_GATE_dBThreshold_in: bytes
_GATE_dBDamping_max: bytes
_GATE_BP_Sidechain: bytes
_GATE_attack_ms: bytes
_GATE_hold_ms: bytes
_GATE_release_ms: bytes
_DenoiserThreshold: bytes
_PitchEnabled: bytes
_Pitch_DryWet: bytes
_Pitch_Value: bytes
_Pitch_formant_lo: bytes
_Pitch_formant_med: bytes
_Pitch_formant_high: bytes
@classmethod
def from_bytes(cls, data: bytes):
return cls(
_mode=data[0:4],
_dblevel=data[4:8],
_audibility=data[8:10],
_pos3D_x=data[10:12],
_pos3D_y=data[12:14],
_posColor_x=data[14:16],
_posColor_y=data[16:18],
_EQgain1=data[18:20],
_EQgain2=data[20:22],
_EQgain3=data[22:24],
_PEQ_eqOn=data[24:30],
_PEQ_eqtype=data[30:36],
_PEQ_eqgain=data[36:60],
_PEQ_eqfreq=data[60:84],
_PEQ_eqq=data[84:108],
_audibility_c=data[108:110],
_audibility_g=data[110:112],
_audibility_d=data[112:114],
_posMod_x=data[114:116],
_posMod_y=data[116:118],
_send_reverb=data[118:120],
_send_delay=data[120:122],
_send_fx1=data[122:124],
_send_fx2=data[124:126],
_dblimit=data[126:128],
_nKaraoke=data[128:130],
_COMP_gain_in=data[130:132],
_COMP_attack_ms=data[132:134],
_COMP_release_ms=data[134:136],
_COMP_n_knee=data[136:138],
_COMP_comprate=data[138:140],
_COMP_threshold=data[140:142],
_COMP_c_enabled=data[142:144],
_COMP_c_auto=data[144:146],
_COMP_gain_out=data[146:148],
_GATE_dBThreshold_in=data[148:150],
_GATE_dBDamping_max=data[150:152],
_GATE_BP_Sidechain=data[152:154],
_GATE_attack_ms=data[154:156],
_GATE_hold_ms=data[156:158],
_GATE_release_ms=data[158:160],
_DenoiserThreshold=data[160:162],
_PitchEnabled=data[162:164],
_Pitch_DryWet=data[164:166],
_Pitch_Value=data[166:168],
_Pitch_formant_lo=data[168:170],
_Pitch_formant_med=data[170:172],
_Pitch_formant_high=data[172:174],
)
@property
def mode(self) -> int:
return int.from_bytes(self._mode, 'little')
@property
def audibility(self) -> Audibility:
return Audibility(
round(int.from_bytes(self._audibility, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._audibility_c, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._audibility_g, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._audibility_d, 'little', signed=True) * 0.01, 2),
)
@property
def positions(self) -> Positions:
return Positions(
round(int.from_bytes(self._pos3D_x, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._pos3D_y, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._posColor_x, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._posColor_y, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._posMod_x, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._posMod_y, 'little', signed=True) * 0.01, 2),
)
@property
def eqgains(self) -> EqGains:
return EqGains(
*[
round(
int.from_bytes(getattr(self, f'_EQgain{i}'), 'little', signed=True)
* 0.01,
2,
)
for i in range(1, 4)
]
)
@property
def parametric_eq(self) -> tuple[ParametricEQSettings, ...]:
return tuple(
ParametricEQSettings(
on=bool(int.from_bytes(self._PEQ_eqOn[i : i + 1], 'little')),
type=int.from_bytes(self._PEQ_eqtype[i : i + 1], 'little'),
freq=struct.unpack('<f', self._PEQ_eqfreq[i * 4 : (i + 1) * 4])[0],
gain=struct.unpack('<f', self._PEQ_eqgain[i * 4 : (i + 1) * 4])[0],
q=struct.unpack('<f', self._PEQ_eqq[i * 4 : (i + 1) * 4])[0],
)
for i in range(6)
)
@property
def sends(self) -> Sends:
return Sends(
round(int.from_bytes(self._send_reverb, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._send_delay, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._send_fx1, 'little', signed=True) * 0.01, 2),
round(int.from_bytes(self._send_fx2, 'little', signed=True) * 0.01, 2),
)
@property
def karaoke(self) -> int:
return int.from_bytes(self._nKaraoke, 'little')
@property
def compressor(self) -> CompressorSettings:
return CompressorSettings(
gain_in=round(
int.from_bytes(self._COMP_gain_in, 'little', signed=True) * 0.01, 2
),
attack_ms=round(int.from_bytes(self._COMP_attack_ms, 'little') * 0.1, 2),
release_ms=round(int.from_bytes(self._COMP_release_ms, 'little') * 0.1, 2),
n_knee=round(int.from_bytes(self._COMP_n_knee, 'little') * 0.01, 2),
ratio=round(int.from_bytes(self._COMP_comprate, 'little') * 0.01, 2),
threshold=round(
int.from_bytes(self._COMP_threshold, 'little', signed=True) * 0.01, 2
),
c_enabled=bool(int.from_bytes(self._COMP_c_enabled, 'little')),
makeup=bool(int.from_bytes(self._COMP_c_auto, 'little')),
gain_out=round(
int.from_bytes(self._COMP_gain_out, 'little', signed=True) * 0.01, 2
),
)
@property
def gate(self) -> GateSettings:
return GateSettings(
threshold_in=round(
int.from_bytes(self._GATE_dBThreshold_in, 'little', signed=True) * 0.01,
2,
),
damping_max=round(
int.from_bytes(self._GATE_dBDamping_max, 'little', signed=True) * 0.01,
2,
),
bp_sidechain=round(
int.from_bytes(self._GATE_BP_Sidechain, 'little') * 0.1, 2
),
attack_ms=round(int.from_bytes(self._GATE_attack_ms, 'little') * 0.1, 2),
hold_ms=round(int.from_bytes(self._GATE_hold_ms, 'little') * 0.1, 2),
release_ms=round(int.from_bytes(self._GATE_release_ms, 'little') * 0.1, 2),
)
@property
def denoiser(self) -> DenoiserSettings:
return DenoiserSettings(
threshold=round(
int.from_bytes(self._DenoiserThreshold, 'little', signed=True) * 0.01, 2
)
)
@property
def pitch(self) -> PitchSettings:
return PitchSettings(
enabled=bool(int.from_bytes(self._PitchEnabled, 'little')),
dry_wet=round(
int.from_bytes(self._Pitch_DryWet, 'little', signed=True) * 0.01, 2
),
value=round(
int.from_bytes(self._Pitch_Value, 'little', signed=True) * 0.01, 2
),
formant_lo=round(
int.from_bytes(self._Pitch_formant_lo, 'little', signed=True) * 0.01, 2
),
formant_med=round(
int.from_bytes(self._Pitch_formant_med, 'little', signed=True) * 0.01, 2
),
formant_high=round(
int.from_bytes(self._Pitch_formant_high, 'little', signed=True) * 0.01,
2,
),
)
@dataclass
class VbanPacketNBS1(VbanPacket):
"""Represents the body of a VBAN data packet 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)
),
)

View File

@@ -1,9 +1,17 @@
from abc import abstractmethod import abc
import time
from typing import Union from typing import Union
from . import kinds
from .enums import NBS
from .iremote import IRemote from .iremote import IRemote
from .kinds import kinds_all from .meta import (
from .meta import channel_bool_prop, channel_label_prop, strip_output_prop channel_bool_prop,
channel_label_prop,
send_prop,
strip_output_prop,
xy_prop,
)
class Strip(IRemote): class Strip(IRemote):
@@ -13,13 +21,13 @@ class Strip(IRemote):
Defines concrete implementation for strip Defines concrete implementation for strip
""" """
@abstractmethod @abc.abstractmethod
def __str__(self): def __str__(self):
pass pass
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f'strip[{self.index}]'
@property @property
def limit(self) -> int: def limit(self) -> int:
@@ -27,64 +35,491 @@ class Strip(IRemote):
@limit.setter @limit.setter
def limit(self, val: int): def limit(self, val: int):
self.setter("limit", val) self.setter('limit', val)
@property @property
def gain(self) -> float: def gain(self) -> float:
val = self.getter("gain") val = self.getter('gain')
if val is None: if val is None:
val = self.gainlayer[0].gain val = max(layer.gain for layer in self.gainlayer)
return round(val, 1) return round(val, 1)
@gain.setter @gain.setter
def gain(self, val: float): def gain(self, val: float):
self.setter("gain", val) self.setter('gain', val)
def fadeto(self, target: float, time_: int):
self.setter('FadeTo', f'({target}, {time_})')
time.sleep(self._remote.DELAY)
def fadeby(self, change: float, time_: int):
self.setter('FadeBy', f'({change}, {time_})')
time.sleep(self._remote.DELAY)
class PhysicalStrip(Strip): class PhysicalStrip(Strip):
@classmethod
def make(cls, remote, index, is_phys):
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
return type(
f'PhysicalStrip{remote.kind}',
(cls, EFFECTS_cls),
{
'comp': StripComp(remote, index),
'gate': StripGate(remote, index),
'denoiser': StripDenoiser(remote, index),
'eq': StripEQ.make(remote, index),
},
)
def __str__(self): def __str__(self):
return f"{type(self).__name__}{self.index}" return f'{type(self).__name__}{self.index}'
@property @property
def comp(self) -> float: def audibility(self) -> float:
return if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].audibility.knob
@comp.setter @audibility.setter
def comp(self, val: float): def audibility(self, val: float):
self.setter("Comp", val) self.setter('audibility', val)
class StripComp(IRemote):
@property
def identifier(self) -> str:
return f'strip[{self.index}].comp'
@property @property
def gate(self) -> float: def knob(self) -> float:
return if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].audibility.comp
@gate.setter @knob.setter
def gate(self, val: float): def knob(self, val: float):
self.setter("gate", val) self.setter('', val)
@property @property
def device(self): 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)
@property @property
def sr(self): def ratio(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.ratio
@ratio.setter
def ratio(self, val: float):
self.setter('Ratio', val)
@property
def threshold(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.threshold
@threshold.setter
def threshold(self, val: float):
self.setter('Threshold', val)
@property
def attack(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.attack_ms
@attack.setter
def attack(self, val: float):
self.setter('Attack', val)
@property
def release(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.release_ms
@release.setter
def release(self, val: float):
self.setter('Release', val)
@property
def knee(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.n_knee
@knee.setter
def knee(self, val: float):
self.setter('Knee', val)
@property
def gainout(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].compressor.gain_out
@gainout.setter
def gainout(self, val: float):
self.setter('GainOut', val)
@property
def makeup(self) -> bool:
if self.public_packets[NBS.one] is None:
return False
return bool(self.public_packets[NBS.one].strips[self.index].compressor.makeup)
@makeup.setter
def makeup(self, val: bool):
self.setter('makeup', 1 if val else 0)
class StripGate(IRemote):
@property
def identifier(self) -> str:
return f'strip[{self.index}].gate'
@property
def knob(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].audibility.gate
@knob.setter
def knob(self, val: float):
self.setter('', val)
@property
def threshold(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].gate.threshold_in
@threshold.setter
def threshold(self, val: float):
self.setter('Threshold', val)
@property
def damping(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].gate.damping_max
@damping.setter
def damping(self, val: float):
self.setter('Damping', val)
@property
def bpsidechain(self) -> int:
if self.public_packets[NBS.one] is None:
return 0
return self.public_packets[NBS.one].strips[self.index].gate.bp_sidechain
@bpsidechain.setter
def bpsidechain(self, val: int):
self.setter('BPSidechain', val)
@property
def attack(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].gate.attack_ms
@attack.setter
def attack(self, val: float):
self.setter('Attack', val)
@property
def hold(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].gate.hold_ms
@hold.setter
def hold(self, val: float):
self.setter('Hold', val)
@property
def release(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].gate.release_ms
@release.setter
def release(self, val: float):
self.setter('Release', val)
class StripDenoiser(IRemote):
@property
def identifier(self) -> str:
return f'strip[{self.index}].denoiser'
@property
def knob(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].audibility.denoiser
@knob.setter
def knob(self, val: float):
self.setter('', val)
class StripEQ(IRemote):
@classmethod
def make(cls, remote, i):
"""
Factory method for Strip EQ.
Returns a StripEQ class.
"""
STRIPEQ_cls = type(
'StripEQ',
(cls,),
{
'channel': tuple(
StripEQCh.make(remote, i, j)
for j in range(remote.kind.strip_channels)
)
},
)
return STRIPEQ_cls(remote, i)
@property
def identifier(self) -> str:
return f'strip[{self.index}].eq'
@property
def on(self):
return return
@on.setter
def on(self, val: bool):
self.setter('on', 1 if val else 0)
@property
def ab(self):
return
@ab.setter
def ab(self, val: bool):
self.setter('ab', 1 if val else 0)
class StripEQCh(IRemote):
@classmethod
def make(cls, remote, i, j):
"""
Factory method for Strip EQ channel.
Returns a StripEQCh class.
"""
StripEQCh_cls = type(
'StripEQCh',
(cls,),
{
'cell': tuple(
StripEQChCell(remote, i, j, k) for k in range(remote.kind.cells)
)
},
)
return StripEQCh_cls(remote, i, j)
def __init__(self, remote, i, j):
super().__init__(remote, i)
self.channel_index = j
@property
def identifier(self) -> str:
return f'Strip[{self.index}].eq.channel[{self.channel_index}]'
class StripEQChCell(IRemote):
def __init__(self, remote, i, j, k):
super().__init__(remote, i)
self.channel_index = j
self.cell_index = k
@property
def identifier(self) -> str:
return f'Strip[{self.index}].eq.channel[{self.channel_index}].cell[{self.cell_index}]'
@property
def on(self) -> bool:
if self.channel_index > 0:
self.logger.warning(
'Only channel 0 is supported over VBAN for Strip EQ cells'
)
if self.public_packets[NBS.one] is None:
return False
return (
self.public_packets[NBS.one]
.strips[self.index]
.parametric_eq[self.cell_index]
.on
)
@on.setter
def on(self, val: bool):
self.setter('on', 1 if val else 0)
@property
def type(self) -> int:
if self.channel_index > 0:
self.logger.warning(
'Only channel 0 is supported over VBAN for Strip EQ cells'
)
if self.public_packets[NBS.one] is None:
return 0
return (
self.public_packets[NBS.one]
.strips[self.index]
.parametric_eq[self.cell_index]
.type
)
@type.setter
def type(self, val: int):
self.setter('type', val)
@property
def f(self) -> float:
if self.channel_index > 0:
self.logger.warning(
'Only channel 0 is supported over VBAN for Strip EQ cells'
)
if self.public_packets[NBS.one] is None:
return 0.0
return (
self.public_packets[NBS.one]
.strips[self.index]
.parametric_eq[self.cell_index]
.freq
)
@f.setter
def f(self, val: float):
self.setter('f', val)
@property
def gain(self) -> float:
if self.channel_index > 0:
self.logger.warning(
'Only channel 0 is supported over VBAN for Strip EQ cells'
)
if self.public_packets[NBS.one] is None:
return 0.0
return (
self.public_packets[NBS.one]
.strips[self.index]
.parametric_eq[self.cell_index]
.gain
)
@gain.setter
def gain(self, val: float):
self.setter('gain', val)
@property
def q(self) -> float:
if self.channel_index > 0:
self.logger.warning(
'Only channel 0 is supported over VBAN for Strip EQ cells'
)
if self.public_packets[NBS.one] is None:
return 0.0
return (
self.public_packets[NBS.one]
.strips[self.index]
.parametric_eq[self.cell_index]
.q
)
@q.setter
def q(self, val: float):
self.setter('q', val)
class VirtualStrip(Strip): class VirtualStrip(Strip):
def __str__(self): @classmethod
return f"{type(self).__name__}{self.index}" def make(cls, remote, i, is_phys):
"""
Factory method for VirtualStrip.
mc = channel_bool_prop("mc") Returns a VirtualStrip class.
"""
EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
return type(
'VirtualStrip',
(cls, EFFECTS_cls),
{},
)
def __str__(self):
return f'{type(self).__name__}{self.index}'
mc = channel_bool_prop('mc')
mono = mc mono = mc
@property @property
def k(self) -> int: def k(self) -> int:
return if self.public_packets[NBS.one] is None:
return 0
return self.public_packets[NBS.one].strips[self.index].karaoke
@k.setter @k.setter
def k(self, val: int): def k(self, val: int):
self.setter("karaoke", val) self.setter('karaoke', val)
@property
def bass(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].eqgains.bass
@bass.setter
def bass(self, val: float):
self.setter('EQGain1', val)
@property
def mid(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].eqgains.mid
@mid.setter
def mid(self, val: float):
self.setter('EQGain2', val)
med = mid
@property
def treble(self) -> float:
if self.public_packets[NBS.one] is None:
return 0.0
return self.public_packets[NBS.one].strips[self.index].eqgains.treble
@treble.setter
def treble(self, val: float):
self.setter('EQGain3', val)
high = treble
def appgain(self, name: str, gain: float):
self.setter('AppGain', f'("{name}", {gain})')
def appmute(self, name: str, mute: bool = None):
self.setter('AppMute', f'("{name}", {1 if mute else 0})')
class StripLevel(IRemote): class StripLevel(IRemote):
@@ -103,14 +538,17 @@ class StripLevel(IRemote):
self.range = self.level_map[self.index] self.range = self.level_map[self.index]
def getter(self): def getter(self):
return tuple( """Returns a tuple of level values for the channel."""
round(-i * 0.01, 1)
for i in self._remote.cache["strip_level"][self.range[0] : self.range[-1]] if not self._remote.stopped() and self._remote.event.ldirty:
) return self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
return self.public_packets[NBS.zero].levels.strip[
self.range[0] : self.range[-1]
]
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f'strip[{self.index}]'
@property @property
def prefader(self) -> tuple: def prefader(self) -> tuple:
@@ -143,36 +581,28 @@ class GainLayer(IRemote):
@property @property
def identifier(self) -> str: def identifier(self) -> str:
return f"Strip[{self.index}]" return f'strip[{self.index}]'
@property @property
def gain(self) -> float: def gain(self) -> float:
def fget(): val = self.getter(f'GainLayer[{self._i}]')
val = getattr(self.public_packet, f"stripgainlayer{self._i+1}")[self.index] if val:
if val < 10000: return round(val, 2)
return -val
elif val == ((1 << 16) - 1):
return 0
else: else:
return ((1 << 16) - 1) - val return self.public_packets[NBS.zero].gainlayers[self._i][self.index]
val = self.getter(f"GainLayer[{self._i}]")
if val is None:
val = fget() * 0.01
return round(val, 1)
@gain.setter @gain.setter
def gain(self, val: float): def gain(self, val: float):
self.setter(f"GainLayer[{self._i}]", val) self.setter(f'GainLayer[{self._i}]', val)
def _make_gainlayer_mixin(remote, index): def _make_gainlayer_mixin(remote, index):
"""Creates a GainLayer mixin""" """Creates a GainLayer mixin"""
return type( return type(
f"GainlayerMixin", 'GainlayerMixin',
(), (),
{ {
"gainlayer": tuple( 'gainlayer': tuple(
GainLayer(remote, index, i) for i in range(remote.kind.num_bus) GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
) )
}, },
@@ -182,24 +612,78 @@ def _make_gainlayer_mixin(remote, index):
def _make_channelout_mixin(kind): def _make_channelout_mixin(kind):
"""Creates a channel out property mixin""" """Creates a channel out property mixin"""
return type( return type(
f"ChannelOutMixin{kind}", f'ChannelOutMixin{kind}',
(), (),
{ {
**{ **{
f"A{i}": strip_output_prop(f"A{i}") for i in range(1, kind.phys_out + 1) f'A{i}': strip_output_prop(f'A{i}') for i in range(1, kind.phys_out + 1)
}, },
**{ **{
f"B{i}": strip_output_prop(f"B{i}") for i in range(1, kind.virt_out + 1) f'B{i}': strip_output_prop(f'B{i}') for i in range(1, kind.virt_out + 1)
}, },
}, },
) )
_make_channelout_mixins = { _make_channelout_mixins = {
kind.name: _make_channelout_mixin(kind) for kind in kinds_all kind.name: _make_channelout_mixin(kind) for kind in kinds.all
} }
def _make_effects_mixin(kind, is_phys):
"""creates an effects mixin for a kind"""
def _make_xy_cls():
pan = {param: xy_prop(param) for param in ['pan_x', 'pan_y']}
color = {param: xy_prop(param) for param in ['color_x', 'color_y']}
fx = {param: xy_prop(param) for param in ['fx_x', 'fx_y']}
if is_phys:
return type(
'XYPhys',
(),
{
**pan,
**color,
**fx,
},
)
return type(
'XYVirt',
(),
{**pan},
)
def _make_sends_cls():
if is_phys:
return type(
'FX',
(),
{
**{
param: send_prop(param)
for param in ['reverb', 'delay', 'fx1', 'fx2']
},
# **{
# f'post{param}': bool_prop(f'post{param}')
# for param in ['reverb', 'delay', 'fx1', 'fx2']
# },
},
)
return type('FX', (), {})
if kind.name == 'basic':
steps = (_make_xy_cls,)
elif kind.name == 'banana':
steps = (_make_xy_cls,)
elif kind.name == 'potato':
steps = (_make_xy_cls, _make_sends_cls)
return type(f'Effects{kind}', tuple(step() for step in steps), {})
def _make_effects_mixins(is_phys):
return {kind.name: _make_effects_mixin(kind, is_phys) for kind in kinds.all}
def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]: def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]:
""" """
Factory method for strips Factory method for strips
@@ -208,17 +692,21 @@ def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip
Returns a physical or virtual strip subclass Returns a physical or virtual strip subclass
""" """
STRIP_cls = PhysicalStrip if is_phys_strip else VirtualStrip STRIP_cls = (
PhysicalStrip.make(remote, i, is_phys_strip)
if is_phys_strip
else VirtualStrip.make(remote, i, is_phys_strip)
)
CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name] CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i) GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
return type( return type(
f"{STRIP_cls.__name__}{remote.kind}", f'{STRIP_cls.__name__}{remote.kind}',
(STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls), (STRIP_cls, CHANNELOUTMIXIN_cls, GAINLAYERMIXIN_cls),
{ {
"levels": StripLevel(remote, i), 'levels': StripLevel(remote, i),
**{param: channel_bool_prop(param) for param in ["mono", "solo", "mute"]}, **{param: channel_bool_prop(param) for param in ['mono', 'solo', 'mute']},
"label": channel_label_prop(), 'label': channel_label_prop(),
}, },
)(remote, i) )(remote, i)

View File

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

View File

@@ -1,4 +1,3 @@
from enum import IntEnum
from typing import Iterator from typing import Iterator
@@ -7,9 +6,24 @@ def cache_bool(func, param):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
self, *rem = args self, *rem = args
cmd = f"{self.identifier}.{param}" if self._cmd(param) in self._remote.cache:
if cmd in self._remote.cache: return self._remote.cache.pop(self._cmd(param)) == 1
return self._remote.cache.pop(cmd) == 1 if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs)
return wrapper
def cache_int(func, param):
"""Check cache for an int prop"""
def wrapper(*args, **kwargs):
self, *rem = args
if self._cmd(param) in self._remote.cache:
return self._remote.cache.pop(self._cmd(param))
if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
@@ -20,9 +34,24 @@ def cache_string(func, param):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
self, *rem = args self, *rem = args
cmd = f"{self.identifier}.{param}" if self._cmd(param) in self._remote.cache:
if cmd in self._remote.cache: return self._remote.cache.pop(self._cmd(param)).strip('"')
return self._remote.cache.pop(cmd) if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs)
return wrapper
def cache_float(func, param):
"""Check cache for a float prop"""
def wrapper(*args, **kwargs):
self, *rem = args
if self._cmd(param) in self._remote.cache:
return round(self._remote.cache.pop(self._cmd(param)), 2)
if self._remote.sync:
self._remote.clear_dirty()
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
@@ -34,37 +63,39 @@ def depth(d):
return 0 return 0
def script(func):
"""Convert dictionary to script"""
def wrapper(*args):
remote, script = args
if isinstance(script, dict):
params = ""
for key, val in script.items():
obj, m2, *rem = key.split("-")
index = int(m2) if m2.isnumeric() else int(*rem)
params += ";".join(
f"{obj}{f'.{m2}stream' if not m2.isnumeric() else ''}[{index}].{k}={int(v) if isinstance(v, bool) else v}"
for k, v in val.items()
)
params += ";"
script = params
return func(remote, script)
return wrapper
def comp(t0: tuple, t1: tuple) -> Iterator[bool]: def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
""" """
Generator function, accepts two tuples. Generator function, accepts two tuples of dB values.
Evaluates equality of each member in both tuples. Evaluates equality of each member in both tuples.
Only ignores changes when levels are very quiet (below -72 dB).
""" """
for a, b in zip(t0, t1): for a, b in zip(t0, t1):
if b <= 9500: # If both values are very quiet (below -72dB), ignore small changes
yield a == b if a <= -72.0 and b <= -72.0:
yield True yield a == b # Both quiet, check if they're equal
else:
yield a != b # At least one has significant level, detect changes
Socket = IntEnum("Socket", "register request response", start=0) def deep_merge(dict1, dict2):
"""Generator function for deep merging two dicts"""
for k in set(dict1) | set(dict2):
if k in dict1 and k in dict2:
if isinstance(dict1[k], dict) and isinstance(dict2[k], dict):
yield k, dict(deep_merge(dict1[k], dict2[k]))
else:
yield k, dict2[k]
elif k in dict1:
yield k, dict1[k]
else:
yield k, dict2[k]
def bump_framecounter(framecounter: int) -> int:
"""Increment framecounter with rollover at 0xFFFFFFFF."""
if framecounter > 0xFFFFFFFF:
return 0
else:
return framecounter + 1

234
vban_cmd/vban.py Normal file
View File

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

238
vban_cmd/vbancmd.py Normal file
View File

@@ -0,0 +1,238 @@
import abc
import logging
import socket
import threading
import time
from pathlib import Path
from queue import Queue
from typing import Union
from .enums import NBS
from .error import VBANCMDError
from .event import Event
from .packet.headers import VbanMatrixResponseHeader, VbanRequestHeader
from .subject import Subject
from .util import bump_framecounter, deep_merge
from .worker import Producer, Subscriber, Updater
logger = logging.getLogger(__name__)
class VbanCmd(abc.ABC):
"""Abstract Base Class for Voicemeeter VBAN Command Interfaces"""
DELAY = 0.001
# fmt: off
BPS_OPTS = [
0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
1000000, 1500000, 2000000, 3000000,
]
# fmt: on
def __init__(self, **kwargs):
self.logger = logger.getChild(self.__class__.__name__)
self.event = Event({k: kwargs.pop(k) for k in ('pdirty', 'ldirty')})
if not kwargs['ip']:
kwargs |= self._conn_from_toml()
for attr, val in kwargs.items():
setattr(self, attr, val)
self._framecounter = 0
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.subject = self.observer = Subject()
self.cache = {}
self._pdirty = False
self._ldirty = False
self._script = str()
self.stop_event = None
self.producer = None
@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 # type: ignore[import]
def get_filepath():
for pn in (
Path.cwd() / 'vban.toml',
Path.cwd() / 'configs' / 'vban.toml',
Path.home() / '.config' / 'vban-cmd' / 'vban.toml',
Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / 'vban.toml',
):
if pn.exists():
return pn
if not (filepath := get_filepath()):
raise VBANCMDError('no ip provided and no vban.toml located.')
try:
with open(filepath, 'rb') as f:
return tomllib.load(f)['connection']
except tomllib.TomlDecodeError as e:
raise VBANCMDError(f'Error decoding {filepath}: {e}') from e
def __enter__(self):
self.login()
return self
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
self.logout()
def login(self) -> None:
"""Starts the subscriber and updater threads (unless disable_rt_listeners is True) and logs into Voicemeeter."""
if not self.disable_rt_listeners:
self.event.info()
self.stop_event = threading.Event()
self.stop_event.clear()
self.subscriber = Subscriber(self, self.stop_event)
self.subscriber.start()
queue = Queue()
self.updater = Updater(self, queue)
self.updater.start()
self.producer = Producer(self, queue, self.stop_event)
self.producer.start()
self.logger.info(
"Successfully logged into VBANCMD {kind} with ip='{ip}', port={port}, streamname='{streamname}'".format(
**self.__dict__
)
)
def logout(self) -> None:
if not self.stopped():
self.logger.debug('events thread shutdown started')
self.stop_event.set()
if self.producer is not None:
for t in (self.producer, self.subscriber):
t.join()
self.sock.close()
self.logger.info(f'{type(self).__name__}: Successfully logged out of {self}')
def stopped(self):
return self.stop_event is None or self.stop_event.is_set()
def _send_request(self, payload: str) -> None:
"""Sends a request packet over the network and bumps the framecounter."""
self.sock.sendto(
VbanRequestHeader.encode_with_payload(
name=self.streamname,
bps_index=self.BPS_OPTS.index(self.bps),
channel=self.channel,
framecounter=self._framecounter,
payload=payload,
),
(socket.gethostbyname(self.ip), self.port),
)
self._framecounter = bump_framecounter(self._framecounter)
def _set_rt(self, cmd: str, val: Union[str, float]):
"""Sends a string request command over a network."""
self._send_request(f'{cmd}={val};')
self.cache[cmd] = val
def sendtext(self, script) -> str | None:
"""Sends a multiple parameter string over a network."""
self._send_request(script)
self.logger.debug(f'sendtext: {script}')
if self.disable_rt_listeners and script.endswith(('?', '?;')):
try:
response = VbanMatrixResponseHeader.extract_payload(
self.sock.recv(1024)
)
return response
except ValueError as e:
self.logger.warning(f'Error extracting matrix response: {e}')
time.sleep(self.DELAY)
@property
def type(self) -> str:
"""Returns the type of Voicemeeter installation."""
return self.public_packets[NBS.zero].voicemeetertype
@property
def version(self) -> str:
"""Returns Voicemeeter's version as a string"""
return '{0}.{1}.{2}.{3}'.format(
*self.public_packets[NBS.zero].voicemeeterversion
)
@property
def pdirty(self):
"""True iff a parameter has changed"""
return self._pdirty
@property
def ldirty(self):
"""True iff a level value has changed."""
return self._ldirty
@property
def public_packets(self):
return self._public_packets
def clear_dirty(self) -> None:
while self.pdirty:
time.sleep(self.DELAY)
def apply(self, data: dict):
"""
Sets all parameters of a dict
minor delay between each recursion
"""
def target(key):
match key.split('-'):
case ['strip' | 'bus' as kls, index] if index.isnumeric():
target = getattr(self, kls)
case [
'vban',
'in' | 'instream' | 'out' | 'outstream' as direction,
index,
] if index.isnumeric():
target = getattr(
self.vban, f'{direction.removesuffix("stream")}stream'
)
case _:
ERR_MSG = f"invalid config key '{key}'"
self.logger.error(ERR_MSG)
raise ValueError(ERR_MSG)
return target[int(index)]
[target(key).apply(di).then_wait() for key, di in data.items()]
def apply_config(self, name):
"""applies a config from memory"""
ERR_MSG = (
f"No config with name '{name}' is loaded into memory",
f'Known configs: {list(self.configs.keys())}',
)
try:
config = self.configs[name]
except KeyError as e:
self.logger.error(('\n').join(ERR_MSG))
raise VBANCMDError(('\n').join(ERR_MSG)) from e
if 'extends' in config:
extended = config['extends']
config = {
k: v
for k, v in deep_merge(self.configs[extended], config)
if k not in ('extends')
}
self.logger.debug(
f"profile '{name}' extends '{extended}', profiles merged.."
)
self.apply(config)
self.logger.info(f"Profile '{name}' applied!")

View File

@@ -1,40 +1,146 @@
import logging
import socket import socket
import threading import threading
import time import time
from enum import IntEnum
from typing import Optional
from .packet import ( from .enums import NBS
from .error import VBANCMDConnectionError
from .packet.headers import (
HEADER_SIZE, HEADER_SIZE,
RegisterRTHeader, VbanPacket,
VBAN_VMRT_Packet_Data, VbanResponseHeader,
VBAN_VMRT_Packet_Header, VbanSubscribeHeader,
) )
from .util import Socket from .packet.nbs0 import VbanPacketNBS0
from .packet.nbs1 import VbanPacketNBS1
from .util import bump_framecounter
logger = logging.getLogger(__name__)
class Subscriber(threading.Thread): class Subscriber(threading.Thread):
"""fire a subscription packet every 10 seconds""" """fire a subscription packet every 10 seconds"""
def __init__(self, remote): def __init__(self, remote, stop_event):
super().__init__(name="subscriber", target=self.register, daemon=True) super().__init__(name='subscriber', daemon=False)
self._rem = remote self._remote = remote
self.register_header = RegisterRTHeader() self.stop_event = stop_event
self.logger = logger.getChild(self.__class__.__name__)
self._framecounter = 0
def register(self): def run(self):
while self._rem.running: while not self.stopped():
try: try:
self._rem.socks[Socket.register].sendto( for nbs in NBS:
self.register_header.header, sub_packet = VbanSubscribeHeader().to_bytes(nbs, self._framecounter)
(socket.gethostbyname(self._rem.ip), self._rem.port), self._remote.sock.sendto(
sub_packet, (self._remote.ip, self._remote.port)
) )
count = int.from_bytes(self.register_header.framecounter, "little") + 1 self._framecounter = bump_framecounter(self._framecounter)
self.register_header.framecounter = count.to_bytes(4, "little")
time.sleep(10) self.wait_until_stopped(10)
except socket.gaierror as e: except socket.gaierror as e:
print(f"Unable to resolve hostname {self._rem.ip}") self.logger.exception(f'{type(e).__name__}: {e}')
self._rem.socks[Socket.register].close() raise VBANCMDConnectionError(
raise e f'unable to resolve hostname {self._remote.ip}'
) from e
self.logger.debug(f'terminating {self.name} thread')
def stopped(self):
return self.stop_event.is_set()
def wait_until_stopped(self, timeout, period=0.2):
must_end = time.time() + timeout
while time.time() < must_end:
if self.stopped():
break
time.sleep(period)
class Producer(threading.Thread):
"""Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
def __init__(self, remote, queue, stop_event):
super().__init__(name='producer', daemon=False)
self._remote = remote
self.queue = queue
self.stop_event = stop_event
self.logger = logger.getChild(self.__class__.__name__)
self._remote.sock.settimeout(self._remote.timeout)
self._remote._public_packets = [None] * (max(NBS) + 1)
_pp = self._get_rt()
self._remote._public_packets[_pp.nbs] = _pp
(
self._remote.cache['strip_level'],
self._remote.cache['bus_level'],
) = self._remote.public_packets[NBS.zero].levels
def _get_rt(self) -> VbanPacket:
"""Attempt to fetch data packet until a valid one found"""
while True:
if resp := self._fetch_rt_packet():
return resp
def _fetch_rt_packet(self) -> VbanPacket | None:
try:
data, _ = self._remote.sock.recvfrom(2048)
if len(data) < HEADER_SIZE:
return
except TimeoutError as e:
self.logger.exception(f'{type(e).__name__}: {e}')
raise VBANCMDConnectionError(
f'timeout waiting for response from {self._remote.ip}:{self._remote.port}'
) from e
try:
header = VbanResponseHeader.from_bytes(data[:HEADER_SIZE])
except ValueError as e:
self.logger.warning(f'Error parsing response packet: {e}')
return None
match header.format_nbs:
case NBS.zero:
return VbanPacketNBS0.from_bytes(
nbs=NBS.zero, kind=self._remote.kind, data=data
)
case NBS.one:
return VbanPacketNBS1.from_bytes(
nbs=NBS.one, kind=self._remote.kind, data=data
)
return None
def stopped(self):
return self.stop_event.is_set()
def run(self):
while not self.stopped():
pdirty = ldirty = False
_pp = self._get_rt()
match _pp.nbs:
case NBS.zero:
ldirty = _pp.ldirty(
self._remote.cache['strip_level'],
self._remote.cache['bus_level'],
)
pdirty = _pp.pdirty(self._remote.public_packets[NBS.zero])
case NBS.one:
pdirty = True
if pdirty or ldirty:
self._remote._public_packets[_pp.nbs] = _pp
self._remote._pdirty = pdirty
self._remote._ldirty = ldirty
if self._remote.event.pdirty:
self.queue.put('pdirty')
if self._remote.event.ldirty:
self.queue.put('ldirty')
# time.sleep(self._remote.ratelimit)
self.logger.debug(f'terminating {self.name} thread')
self.queue.put(None)
class Updater(threading.Thread): class Updater(threading.Thread):
@@ -44,80 +150,31 @@ class Updater(threading.Thread):
notifies observers of event updates notifies observers of event updates
""" """
def __init__(self, remote): def __init__(self, remote, queue):
super().__init__(name="updater", target=self.update, daemon=True) super().__init__(name='updater', daemon=True)
self._rem = remote self._remote = remote
self._rem.socks[Socket.response].bind( self.queue = queue
(socket.gethostbyname(socket.gethostname()), self._rem.port) self.logger = logger.getChild(self.__class__.__name__)
self._remote._strip_comp = [False] * (self._remote.kind.num_strip_levels)
self._remote._bus_comp = [False] * (self._remote.kind.num_bus_levels)
def run(self):
"""
Continously update observers of dirty states.
Generate _strip_comp, _bus_comp and update level cache if ldirty.
"""
while event := self.queue.get():
if event == 'pdirty' and self._remote.pdirty:
self._remote.subject.notify(event)
elif event == 'ldirty' and self._remote.ldirty:
self._remote._strip_comp, self._remote._bus_comp = (
self._remote._public_packets[NBS.zero]._strip_comp,
self._remote._public_packets[NBS.zero]._bus_comp,
) )
self.expected_packet = VBAN_VMRT_Packet_Header()
self._rem._public_packet = self._get_rt()
def _fetch_rt_packet(self) -> Optional[VBAN_VMRT_Packet_Data]:
"""Returns a valid RT Data Packet or None"""
data, _ = self._rem.socks[Socket.response].recvfrom(2048)
# check for packet data
if len(data) > HEADER_SIZE:
# check if packet is of type VBAN
if self.expected_packet.header == data[: HEADER_SIZE - 4]:
return VBAN_VMRT_Packet_Data(
_voicemeeterType=data[28:29],
_reserved=data[29:30],
_buffersize=data[30:32],
_voicemeeterVersion=data[32:36],
_optionBits=data[36:40],
_samplerate=data[40:44],
_inputLeveldB100=data[44:112],
_outputLeveldB100=data[112:240],
_TransportBit=data[240:244],
_stripState=data[244:276],
_busState=data[276:308],
_stripGaindB100Layer1=data[308:324],
_stripGaindB100Layer2=data[324:340],
_stripGaindB100Layer3=data[340:356],
_stripGaindB100Layer4=data[356:372],
_stripGaindB100Layer5=data[372:388],
_stripGaindB100Layer6=data[388:404],
_stripGaindB100Layer7=data[404:420],
_stripGaindB100Layer8=data[420:436],
_busGaindB100=data[436:452],
_stripLabelUTF8c60=data[452:932],
_busLabelUTF8c60=data[932:1412],
)
def _get_rt(self) -> VBAN_VMRT_Packet_Data:
"""Attempt to fetch data packet until a valid one found"""
def fget():
data = False
while not data:
data = self._fetch_rt_packet()
time.sleep(self._rem.DELAY)
return data
return fget()
def update(self):
print(f"Listening for {', '.join(self._rem.event.get())} events")
( (
self._rem.cache["strip_level"], self._remote.cache['strip_level'],
self._rem.cache["bus_level"], self._remote.cache['bus_level'],
) = self._rem._get_levels(self._rem.public_packet) ) = self._remote.public_packets[NBS.zero].levels
self._remote.subject.notify(event)
while self._rem.running: self.logger.debug(f'terminating {self.name} thread')
start = time.time()
_pp = self._get_rt()
self._rem._strip_buf, self._rem._bus_buf = self._rem._get_levels(_pp)
self._rem._pdirty = _pp.pdirty(self._rem.public_packet)
if self._rem.event.ldirty and self._rem.ldirty:
self._rem.cache["strip_level"] = self._rem._strip_buf
self._rem.cache["bus_level"] = self._rem._bus_buf
self._rem.subject.notify("ldirty")
if self._rem.public_packet != _pp:
self._rem._public_packet = _pp
if self._rem.event.pdirty and self._rem.pdirty:
self._rem.subject.notify("pdirty")
elapsed = time.time() - start
if self._rem.ratelimit - elapsed > 0:
time.sleep(self._rem.ratelimit - elapsed)