├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── __main__.py
├── configs
├── banana
│ ├── example.toml
│ └── extender.toml
├── basic
│ ├── example.toml
│ └── extender.toml
└── potato
│ ├── example.toml
│ └── extender.toml
├── examples
├── dsl
│ ├── __main__.py
│ └── setup.py
├── events
│ ├── README.md
│ ├── callbacks
│ │ ├── README.md
│ │ └── __main__.py
│ └── observer
│ │ ├── README.md
│ │ └── __main__.py
├── gui
│ ├── README.md
│ └── __main__.py
├── levels
│ ├── README.md
│ └── __main__.py
├── midi
│ ├── README.md
│ └── __main__.py
└── obs
│ ├── README.md
│ ├── __main__.py
│ └── setup.py
├── poetry.lock
├── pyproject.toml
├── scripts.py
├── tests
├── __init__.py
├── conftest.py
├── reports
│ ├── badge-banana.svg
│ ├── badge-basic.svg
│ └── badge-potato.svg
├── test_configs.py
├── test_errors.py
├── test_factory.py
├── test_higher.py
└── test_lower.py
├── tox.ini
└── voicemeeterlib
├── __init__.py
├── bus.py
├── cbindings.py
├── command.py
├── config.py
├── device.py
├── error.py
├── event.py
├── factory.py
├── inst.py
├── iremote.py
├── kinds.py
├── macrobutton.py
├── meta.py
├── misc.py
├── recorder.py
├── remote.py
├── strip.py
├── subject.py
├── updater.py
├── util.py
└── vban.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
131 | # test reports
132 | tests/reports/
133 | !tests/reports/badge-*.svg
134 |
135 | # test/config
136 | test-*.py
137 | config.toml
138 |
139 | .vscode/
140 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v2.3.0
4 | hooks:
5 | - id: check-yaml
6 | - id: end-of-file-fixer
7 | - id: trailing-whitespace
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | Before any major/minor/patch bump all unit tests will be run to verify they pass.
9 |
10 | ## [Unreleased]
11 |
12 | - [x]
13 |
14 | ## [2.6.0] - 2024-06-29
15 |
16 | ### Added
17 |
18 | - bits kwarg for overriding the type of GUI that is launched on startup.
19 | - Defaults to 64, set it to either 32 or 64.
20 |
21 | ### Fixed
22 |
23 | - {Remote}.run_voicemeeter() now launches x64 bit GUI's for all kinds if Python detects a 64 bit system.
24 |
25 | ## [2.5.0] - 2023-10-27
26 |
27 | ### Fixed
28 |
29 | - {Remote}.login() now has a configurable timeout. Use timeout kwarg to set it. Defaults to 2 seconds.
30 | - Remote class section in README updated to include timeout kwarg.
31 |
32 | ## [2.4.8] - 2023-08-13
33 |
34 | ### Added
35 |
36 | - Error tests added in tests/test_errors.py
37 | - fn_name and code set as class attributes for CAPIError
38 | - Errors section in README updated.
39 |
40 | ### Changed
41 |
42 | - InstallError and CAPIError classes now subclass VMError
43 |
44 | ## [2.3.7] - 2023-08-01
45 |
46 | ### Changed
47 |
48 | - If the configs loader is passed an invalid config TOML it will log an error but continue to load further configs into memory.
49 |
50 | ## [2.3.2] - 2023-07-12
51 |
52 | ### Added
53 |
54 | - vban.{instream,outstream} tuples now contain classes that represent MIDI and TEXT streams.
55 |
56 | ### Fixed
57 |
58 | - apply_config() now performs a deep merge when extending a config with another.
59 |
60 | ## [2.3.0] - 2023-07-11
61 |
62 | ### Added
63 |
64 | - user configs may now extend other user configs. check `config extends` section in README.
65 |
66 | ## [2.2.0] - 2023-07-10
67 |
68 | ### Added
69 |
70 | - CAPIError class now stores fn_name, error code and message as class attributes.
71 |
72 | ### Changed
73 |
74 | - macrobutton capi calls now use error code -9 on AttributeError (using an old version of the API).
75 |
76 | ### Fixed
77 |
78 | - call to `self.vm_get_midi_message` now wrapped by {CBindings}.call.
79 |
80 | ## [2.1.1] - 2023-07-01
81 |
82 | ### Added
83 |
84 | - RecorderMode added to Recorder class. See Recorder section in README for new properties and methods.
85 | - recorder.loop is now a forwarder method for recorder.mode.loop for backwards compatibility
86 |
87 | - RecorderArmStrip, RecorderArmBus mixed into Recorder class.
88 |
89 | ### Removed
90 |
91 | - Recorder.loop removed from documentation
92 |
93 | ### Changed
94 |
95 | - When out of bounds values are passed, log warnings instead of raising Errors. See [Issue #6][Issue 6].
96 |
97 | ## [2.0.0] - 2023-06-25
98 |
99 | Where possible I've attempted to make the changes backwards compatible. The breaking changes affect two higher classes, Strip and Bus, as well as the behaviour of events. All other changes are additive or QOL aimed at giving more options to the developer. For example, every low-level CAPI call is now logged and error raised on Exception, you can now register callback functions as well as observer classes, extra examples to demonstrate different use cases etc.
100 |
101 | The breaking changes are as follows:
102 |
103 | ### Changed
104 |
105 | - `strip[i].comp` now references StripComp class
106 | - To change the comp knob you should now use the property `strip[i].comp.knob`
107 | - `strip[i].gate` now references StripGate class
108 |
109 | - To change the gate knob you should now use the property `strip[i].gate.knob`
110 |
111 | - `bus[i].eq` now references BusEQ class
112 |
113 | - To set bus[i].{eq,eq_ab} as before you should now use bus[i].eq.on and bus[i].eq.ab
114 |
115 | - by default, NO events are checked for. This is reflected in factory.FactoryBase defaultkwargs.
116 | - This is a fundamental behaviour change from version 1.0 of the wrapper. It means the following:
117 | - Unless any events are explicitly requested with an event kwarg the event emitter thread will not run automatically.
118 | - Whether using a context manager or not, you can still initiate the event thread manually and request events with the event object.
119 | see `events` example.
120 |
121 | There are other non-breaking changes:
122 |
123 | ### Added
124 |
125 | - `strip[i].eq` added to PhysicalStrip
126 | - `strip[i].denoiser` added to PhysicalStrip
127 | - `Strip.Comp`, `Strip.Gate`, `Strip.Denoiser` sections added to README.
128 | - `Events` section in readme updated to reflect changes to events kwargs.
129 | - new comp, gate, denoiser and eq tests added to higher tests.
130 | - `levels` example to demonstrate use of the interface without a context manager.
131 | - `events` example to demonstrate how to interact with event thread/event object.
132 | - `gui` example to demonstrate GUI controls.
133 | - `{Remote}.observer` can be used in place of `{Remote}.subject` although subject will still work. Check examples.
134 | - Subject class extended to allow registering/de-registering callback functions (as well as observer classes). See `events` example.
135 |
136 | ### Changed
137 |
138 | - `comp.knob`, `gate.knob`, `denoiser.knob`, `eq.on` added to phys_strip_params in config.TOMLStrBuilder
139 |
140 | - The `example.toml` config files have been updated to demonstrate setting new comp, gate and eq settings.
141 |
142 | - event kwargs can now be set directly. no need for `subs`. example: `voicemeeterlib.api('banana', midi=True})`
143 |
144 | - factorybuilder steps now logged in DEBUG mode.
145 |
146 | - now using a producer thread to send events to the updater thread.
147 |
148 | - module level loggers implemented (with class loggers as child loggers)
149 |
150 | - config.loader now checks `Path.home() / ".config" / "voicemeeter" / kind.name` for configs.
151 | - note. `Path(__file__).parent / "configs" / kind.name,` was removed as a path to check.
152 |
153 | ### Fixed
154 |
155 | - All low level CAPI calls are now wrapped by CBindings.call() which logs any errors raised.
156 | - Dynamic binding of Macrobutton functions from the CAPI.
157 | Should add backwards compatibility with very old versions of the api. See [Issue #4][issue 4].
158 | - factory.request_remote_obj now raises a `VMError` if passed an incorrect kind.
159 |
160 | ## [1.0.0] - 2023-06-19
161 |
162 | No changes to the codebase but it has been stable for several months and should already have been bumped to major version 1.0
163 |
164 | I will move this commit to a separate branch in preparation for version 2.0.
165 |
166 | ## [0.9.0] - 2022-10-11
167 |
168 | ### Added
169 |
170 | - StripDevice and BusDevice mixins.
171 | - README updated to reflect changes.
172 | - Minor version bump
173 |
174 | ### Removed
175 |
176 | - device, sr properties for physical strip, bus moved into mixin classes
177 |
178 | ### Changed
179 |
180 | - Event class property setters added.
181 | - Event add/remove methods now accept multiple events.
182 | - bus levels now printed in observer example.
183 |
184 | ### Fixed
185 |
186 | - initialize channel comps in updater thread. Fixes bug when switching to a kind before any level updates have occurred.
187 |
188 | ## [0.8.0] - 2022-09-29
189 |
190 | ### Added
191 |
192 | - Logging level INFO set on all examples.
193 | - Minor version bump
194 | - vm.subject subsection added to README
195 |
196 | ### Changed
197 |
198 | - Logging module used in place of print statements across the interface.
199 | - time.time() now used to steady rate of updates in updater thread.
200 |
201 | ### Fixed
202 |
203 | - call to cache bug in updater thread
204 |
205 | ## [0.7.0] - 2022-09-03
206 |
207 | ### Added
208 |
209 | - tomli/tomllib compatibility layer to support python 3.10
210 |
211 | ### Removed
212 |
213 | - 3.10 branch
214 |
215 | ## [0.6.0] - 2022-08-02
216 |
217 | ### Added
218 |
219 | - Keyword argument subs for voicemeeterlib.api. Initialize which events to sub to.
220 | - Event class added to misc. Toggle events, get list of currently subscribed.
221 | - voicemeeterlib.api section added to README in Base Module section.
222 | - observer example updated to reflect changes.
223 |
224 | ### Changed
225 |
226 | - By default no longer listen for level updates (high volume). Should be enabled explicitly.
227 |
228 | ## [0.5.0] - 2022-07-24
229 |
230 | ### Added
231 |
232 | - Midi class added to misc.
233 | - Midi added to observer notifications
234 | - Midi example added.
235 | - Midi section added to readme.
236 | - Minor version bump.
237 |
238 | ## [0.4.0] - 2022-07-21
239 |
240 | ### Added
241 |
242 | - asio, insert added to kind maps (maps patching parameters)
243 | - Patch added to misc
244 | - Option added to misc
245 | - Patch, option sections added to readme.
246 | - Patch, option unit tests added
247 | - alias property isdirty for is_updated in strip/bus levels
248 |
249 | ### Changed
250 |
251 | - make_strip_level_map, make_bus_level_map added.
252 | - observer example using isdirty
253 |
254 | ### Fixed
255 |
256 | - error message for vban.sr setter
257 |
258 | ## [0.3.0] - 2022-07-16
259 |
260 | ### Added
261 |
262 | - get() added to bus mode mixin. returns the current bus mode.
263 | - support for all strip level modes in observable thread
264 | - effects parameters mixed into physicalstrip, physicalbus, virtualbus classes
265 | - fx class to potato remote kind (for toggling reverb, delay)
266 | - test_configs to unit tests
267 | - test_factory to unit tests
268 | - fx, xy tests added to higher tests.
269 |
270 | ### Changed
271 |
272 | - observer example switched from strip to bus. easier to test a single input for several buses.
273 |
274 | ### Fixed
275 |
276 | - is_updated in strip/bus levels now returns a bool, is level dirty or not?
277 | - for basic kind only, virtual bus now subclasses physical bus, since it is the only version you may
278 | attach a physical device to a virtual out.
279 |
280 | ### Removed
281 |
282 | - type checks
283 |
284 | ## [0.2.3] - 2022-07-09
285 |
286 | ### Changed
287 |
288 | - only compute strip_comp, bus_comp if ldirty.
289 | - switch from strip to bus in obs example.
290 |
291 | ### Fixed
292 |
293 | - bug in strip fadeto/fadeby
294 | - comp added to util.
295 | - range expressions in vban.
296 |
297 | ## [0.2.0] - 2022-07-02
298 |
299 | ### Added
300 |
301 | - obs added to examples
302 | - Readme updated to reflect changes.
303 | - device, gainlayers, levels, bus mode sections added.
304 | - minor version bump (probably should have been major since changes to ldirty effect client code)
305 |
306 | ### Changed
307 |
308 | - No longer passing data in ldirty notification.
309 | - rw changed to rew in recorder class to match capi
310 |
311 | ## [0.1.10] - 2022-06-28
312 |
313 | ### Added
314 |
315 | - pre-commit.ps1 added for use with git hook
316 |
317 | ### Fixed
318 |
319 | - mdirty added to observer updates
320 | - Error in cbindings
321 |
322 | ## [0.1.9] - 2022-06-21
323 |
324 | ### Added
325 |
326 | - Added sendtext to base class
327 | - script decorator added to util module.
328 | - README initial commit
329 |
330 | ### Changed
331 |
332 | - minor macrobutton refactor
333 |
334 | ### Fixed
335 |
336 | - bug fixed in FactoryBuilder strip, bus iterations.
337 |
338 | ## [0.1.8] - 2022-06-20
339 |
340 | ### Added
341 |
342 | - notify pdirty, ldirty now implemented.
343 |
344 | ### Changed
345 |
346 | - imports now isorted.
347 | - ratelimit now set at default 33ms.
348 | - ldirty modified, no longer sends comp, levels data
349 |
350 | ## [0.1.7] - 2022-06-18
351 |
352 | ### Added
353 |
354 | - added observable thread. (init_thread, end_thread methods added to base class)
355 | - added ldirty parameter
356 | - phys_in, virt_in, phys_out, virt_out properties added to KindMapClass
357 | - ratelimit default kwarg added
358 | - pre-commit.ps1 added for use with git hook
359 | - simple DSL example added
360 |
361 | ### Changed
362 |
363 | - str magic methods overriden in higher classes
364 |
365 | ### Fixed
366 |
367 | - bug in cbindings vm_set_parameter_multi argtypes
368 |
369 | ## [0.1.6] - 2022-06-17
370 |
371 | ### Added
372 |
373 | - Higher class device implemented.
374 | - BusLevel, StripLevel classes added to bus, strip modules respectively.
375 | - float_prop, bus_mode_prop meta functions added to util module.
376 | - bus mode mixin added to bus factory method
377 | - type, version implemented into base class.
378 |
379 | ### Fixed
380 |
381 | - Bug in factory builder
382 |
383 | ## [0.1.5] - 2022-06-14
384 |
385 | ### Added
386 |
387 | - docstrings added
388 |
389 | ### Changed
390 |
391 | - Gainlayer mixed in only if potato kind in Strip factory method.
392 |
393 | ## [0.1.4] - 2022-06-12
394 |
395 | ### Added
396 |
397 | - TOMLStrBuilder class added to config module. Builds a default config as a string for toml parser.
398 | - dataextraction_factory, TOMLDataExtractor added to config module. This allows option for other parser in future.
399 |
400 | ## [0.1.3] - 2022-06-09
401 |
402 | ### Added
403 |
404 | - Added type hints to base module
405 | - Higher classes Bus, PhysicalBus, VirtualBus implemented
406 | - bus module entry point defined.
407 | - Higher class Command implemented
408 | - Config module added. Loader class implemented for tracking configs in memory.
409 | - config entry point defined
410 | - Higher classes Strip, PhysicalStrip, VirtualStrip implemented
411 | - strip module entry point defined
412 | - Higher classes Vban, VbanInstream, VbanOutstream implemented
413 | - A common interface (IRemote) defined. Bridges base to higher classes.
414 |
415 | ## [0.1.2] - 2022-06-07
416 |
417 | ### Added
418 |
419 | - Implement creation class steps as command pattern
420 |
421 | ### Changed
422 |
423 | - Added progress report to FactoryBuilder
424 |
425 | ## [0.1.1] - 2022-06-06
426 |
427 | ### Added
428 |
429 | - move class creation into FactoryBuilder
430 | - creation classes now direct builder class
431 | - added KindId enum
432 | - added Subject module, but not yet implemented
433 |
434 | ### Changed
435 |
436 | - num_strip, num_bus properties added to KindMapClass
437 |
438 | ## [0.1.0] - 2022-06-05
439 |
440 | ### Added
441 |
442 | - cbindings defined
443 | - factory classes added, one for each kind.
444 | - inst module implemented (fetch vm path from registry)
445 | - kind maps implemented as dataclasses
446 | - project packaged with poetry and added to pypi.
447 |
448 | [issue 4]: https://github.com/onyx-and-iris/voicemeeter-api-python/issues/4
449 | [Issue 6]: https://github.com/onyx-and-iris/voicemeeter-api-python/issues/6
450 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Onyx and Iris
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/__main__.py:
--------------------------------------------------------------------------------
1 | import voicemeeterlib
2 |
3 |
4 | class ManyThings:
5 | def __init__(self, vm):
6 | self.vm = vm
7 |
8 | def things(self):
9 | self.vm.strip[0].label = 'podmic'
10 | self.vm.strip[0].mute = True
11 | print(
12 | f'strip 0 ({self.vm.strip[0].label}) mute has been set to {self.vm.strip[0].mute}'
13 | )
14 |
15 | def other_things(self):
16 | self.vm.bus[3].gain = -6.3
17 | self.vm.bus[4].eq.on = True
18 | info = (
19 | f'bus 3 gain has been set to {self.vm.bus[3].gain}',
20 | f'bus 4 eq has been set to {self.vm.bus[4].eq.on}',
21 | )
22 | print('\n'.join(info))
23 |
24 |
25 | def main():
26 | KIND_ID = 'banana'
27 |
28 | with voicemeeterlib.api(KIND_ID) as vm:
29 | do = ManyThings(vm)
30 | do.things()
31 | do.other_things()
32 |
33 | # set many parameters at once
34 | vm.apply(
35 | {
36 | 'strip-2': {'A1': True, 'B1': True, 'gain': -6.0},
37 | 'bus-2': {'mute': True, 'eq': {'on': True}},
38 | 'button-0': {'state': True},
39 | 'vban-in-0': {'on': True},
40 | 'vban-out-1': {'name': 'streamname'},
41 | }
42 | )
43 |
44 |
45 | if __name__ == '__main__':
46 | main()
47 |
--------------------------------------------------------------------------------
/configs/banana/example.toml:
--------------------------------------------------------------------------------
1 | [strip-0]
2 | label = "PhysStrip0"
3 | A1 = true
4 | gain = -8.8
5 | comp.knob = 3.2
6 |
7 | [strip-1]
8 | label = "PhysStrip1"
9 | B1 = true
10 | gate.knob = 4.1
11 |
12 | [strip-2]
13 | label = "PhysStrip2"
14 | gain = 1.1
15 | limit = -15
16 |
17 | [strip-3]
18 | label = "VirtStrip0"
19 | limit = -12
20 |
21 | [strip-4]
22 | label = "VirtStrip1"
23 | bass = -3.2
24 | mid = 1.5
25 | treble = 2.1
26 |
27 | [bus-0]
28 | label = "PhysBus0"
29 | mute = true
30 |
31 | [bus-1]
32 | label = "PhysBus1"
33 | mono = true
34 |
35 | [bus-2]
36 | label = "PhysBus2"
37 | eq.ab = true
38 | mode = "composite"
39 |
40 | [bus-3]
41 | label = "VirtBus0"
42 | eq.on = true
43 | mode = "upmix61"
44 |
45 | [bus-4]
46 | label = "VirtBus1"
47 | gain = -22.8
48 |
--------------------------------------------------------------------------------
/configs/banana/extender.toml:
--------------------------------------------------------------------------------
1 | extends = "example"
2 | [strip-0]
3 | label = "strip0_extended"
4 | A1 = false
5 | gain = 0.0
6 |
7 | [bus-0]
8 | label = "bus0_extended"
9 | mute = false
10 |
11 | [vban-in-3]
12 | name = "vban_extended"
13 |
--------------------------------------------------------------------------------
/configs/basic/example.toml:
--------------------------------------------------------------------------------
1 | [strip-0]
2 | label = "PhysStrip0"
3 | A1 = true
4 | gain = -8.8
5 |
6 | [strip-1]
7 | label = "PhysStrip1"
8 | B1 = true
9 | audibility = 3.2
10 |
11 | [strip-2]
12 | label = "VirtStrip0"
13 | bass = -3.2
14 | mid = 1.5
15 | treble = 2.1
16 |
17 | [bus-0]
18 | label = "PhysBus0"
19 | mute = true
20 | mode = "composite"
21 |
22 | [bus-1]
23 | label = "VirtBus0"
24 | mono = true
25 | mode = "amix"
26 |
--------------------------------------------------------------------------------
/configs/basic/extender.toml:
--------------------------------------------------------------------------------
1 | extends = "example"
2 | [strip-0]
3 | label = "strip0_extended"
4 | A1 = false
5 | gain = 0.0
6 |
7 | [bus-0]
8 | label = "bus0_extended"
9 | mute = false
10 |
11 | [vban-in-3]
12 | name = "vban_extended"
13 |
--------------------------------------------------------------------------------
/configs/potato/example.toml:
--------------------------------------------------------------------------------
1 | [strip-0]
2 | label = "PhysStrip0"
3 | A1 = true
4 | gain = -8.8
5 | comp.knob = 3.2
6 |
7 | [strip-1]
8 | label = "PhysStrip1"
9 | B1 = true
10 | gate.knob = 4.1
11 |
12 | [strip-2]
13 | label = "PhysStrip2"
14 | gain = 1.1
15 | limit = -15
16 | comp.threshold = -35.8
17 |
18 | [strip-3]
19 | label = "PhysStrip3"
20 | B2 = false
21 | eq.on = true
22 |
23 | [strip-4]
24 | label = "PhysStrip4"
25 | B3 = true
26 | gain = -8.8
27 | eq.on = true
28 |
29 | [strip-5]
30 | label = "VirtStrip0"
31 | A3 = true
32 | A5 = true
33 |
34 | [strip-6]
35 | label = "VirtStrip1"
36 | limit = -12
37 | k = 3
38 |
39 | [strip-7]
40 | label = "VirtStrip2"
41 | bass = -3.2
42 | mid = 1.5
43 | treble = 2.1
44 | mc = true
45 |
46 | [bus-0]
47 | label = "PhysBus0"
48 | mute = true
49 |
50 | [bus-1]
51 | label = "PhysBus1"
52 | mono = true
53 |
54 | [bus-2]
55 | label = "PhysBus2"
56 | eq.on = true
57 |
58 | [bus-3]
59 | label = "PhysBus3"
60 | mode = "upmix61"
61 |
62 | [bus-4]
63 | label = "PhysBus4"
64 | mode = "composite"
65 |
66 | [bus-5]
67 | label = "VirtBus0"
68 | eq.ab = true
69 |
70 | [bus-6]
71 | label = "VirtBus1"
72 | gain = -22.8
73 |
74 | [bus-7]
75 | label = "VirtBus2"
76 |
--------------------------------------------------------------------------------
/configs/potato/extender.toml:
--------------------------------------------------------------------------------
1 | extends = "example"
2 | [strip-0]
3 | label = "strip0_extended"
4 | A1 = false
5 | gain = 0.0
6 |
7 | [bus-0]
8 | label = "bus0_extended"
9 | mute = false
10 |
11 | [vban-in-3]
12 | name = "vban_extended"
13 |
--------------------------------------------------------------------------------
/examples/dsl/__main__.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import logging
3 | import time
4 | from abc import ABC, abstractmethod
5 | from enum import IntEnum
6 |
7 | from pyparsing import (
8 | Combine,
9 | Group,
10 | OneOrMore,
11 | Optional,
12 | Suppress,
13 | Word,
14 | alphanums,
15 | nums,
16 | )
17 |
18 | import voicemeeterlib
19 |
20 | logging.basicConfig(level=logging.DEBUG)
21 | logger = logging.getLogger(__name__)
22 |
23 |
24 | argparser = argparse.ArgumentParser(description='creates a basic dsl')
25 | argparser.add_argument('-i', action='store_true')
26 | args = argparser.parse_args()
27 |
28 |
29 | ParamKinds = IntEnum(
30 | 'ParamKinds',
31 | 'bool float string',
32 | )
33 |
34 |
35 | class Strategy(ABC):
36 | def __init__(self, target, param, val):
37 | self.target = target
38 | self.param = param
39 | self.val = val
40 |
41 | @abstractmethod
42 | def run(self):
43 | pass
44 |
45 |
46 | class BoolStrategy(Strategy):
47 | def run(self):
48 | setattr(self.target, self.param, self.strtobool(self.val))
49 |
50 | def strtobool(self, val):
51 | """Convert a string representation of truth to it's numeric form."""
52 |
53 | val = val.lower()
54 | if val in ('y', 'yes', 't', 'true', 'on', '1'):
55 | return 1
56 | elif val in ('n', 'no', 'f', 'false', 'off', '0'):
57 | return 0
58 | else:
59 | raise ValueError('invalid truth value %r' % (val,))
60 |
61 |
62 | class FloatStrategy(Strategy):
63 | def run(self):
64 | setattr(self.target, self.param, float(self.val))
65 |
66 |
67 | class StringStrategy(Strategy):
68 | def run(self):
69 | setattr(self.target, self.param, ' '.join(self.val))
70 |
71 |
72 | class Context:
73 | def __init__(self, strategy: Strategy) -> None:
74 | self._strategy = strategy
75 |
76 | @property
77 | def strategy(self) -> Strategy:
78 | return self._strategy
79 |
80 | @strategy.setter
81 | def strategy(self, strategy: Strategy) -> None:
82 | self._strategy = strategy
83 |
84 | def run(self):
85 | self.strategy.run()
86 |
87 |
88 | class Parser:
89 | IS_STRING = ('label',)
90 |
91 | def __init__(self, vm):
92 | self.logger = logger.getChild(self.__class__.__name__)
93 | self.vm = vm
94 | self.kls = Group(OneOrMore(Word(alphanums)))
95 | self.token = Suppress('->')
96 | self.param = Group(OneOrMore(Word(alphanums)))
97 | self.value = Combine(
98 | Optional('-') + Word(nums) + Optional('.') + Optional(Word(nums))
99 | ) | Group(OneOrMore(Word(alphanums)))
100 | self.event = (
101 | self.kls
102 | + self.token
103 | + self.param
104 | + Optional(self.token)
105 | + Optional(self.value)
106 | )
107 |
108 | def converter(self, cmds):
109 | """determines the kind of parameter from the parsed string"""
110 |
111 | res = list()
112 | for cmd in cmds:
113 | self.logger.debug(f'running command: {cmd}')
114 | match cmd_parsed := self.event.parseString(cmd):
115 | case [[kls, index], [param]]:
116 | target = getattr(self.vm, kls)[int(index)]
117 | res.append(getattr(target, param))
118 | case [[kls, index], [param], val] if param in self.IS_STRING:
119 | target = getattr(self.vm, kls)[int(index)]
120 | context = self._get_context(ParamKinds.string, target, param, val)
121 | context.run()
122 | case [[kls, index], [param], [val] | val]:
123 | target = getattr(self.vm, kls)[int(index)]
124 | try:
125 | context = self._get_context(ParamKinds.bool, target, param, val)
126 | context.run()
127 | except ValueError as e:
128 | self.logger.error(f'{e}... switching to float strategy')
129 | context.strategy = FloatStrategy(target, param, val)
130 | context.run()
131 | case [
132 | [kls, index],
133 | [secondary, param],
134 | [val]
135 | | val,
136 | ]:
137 | primary = getattr(self.vm, kls)[int(index)]
138 | target = getattr(primary, secondary)
139 | try:
140 | context = self._get_context(ParamKinds.bool, target, param, val)
141 | context.run()
142 | except ValueError as e:
143 | self.logger.error(f'{e}... switching to float strategy')
144 | context.strategy = FloatStrategy(target, param, val)
145 | context.run()
146 | case _:
147 | self.logger.error(
148 | f'unable to determine the kind of parameter from {cmd_parsed}'
149 | )
150 | time.sleep(0.05)
151 | return res
152 |
153 | def _get_context(self, kind, *args):
154 | """
155 | determines a strategy for a kind of parameter and passes it to the context.
156 | """
157 |
158 | match kind:
159 | case ParamKinds.bool:
160 | context = Context(BoolStrategy(*args))
161 | case ParamKinds.float:
162 | context = Context(FloatStrategy(*args))
163 | case ParamKinds.string:
164 | context = Context(StringStrategy(*args))
165 | return context
166 |
167 |
168 | def interactive_mode(parser):
169 | while cmd := input('Please enter command (Press to exit)\n'):
170 | if res := parser.parse((cmd,)):
171 | print(res)
172 |
173 |
174 | def main():
175 | # fmt: off
176 | cmds = (
177 | "strip 0 -> mute -> true", "strip 0 -> mute", "bus 0 -> mute -> true",
178 | "strip 0 -> mute -> false", "bus 0 -> mute -> true", "strip 3 -> solo -> true",
179 | "strip 3 -> solo -> false", "strip 1 -> A1 -> true", "strip 1 -> A1",
180 | "strip 1 -> A1 -> false", "strip 1 -> A1", "strip 3 -> eq on -> true",
181 | "bus 3 -> eq on -> false", "strip 4 -> gain -> 1.2", "strip 0 -> gain -> -8.2",
182 | "strip 0 -> gain", "strip 1 -> label -> rode podmic", "strip 2 -> limit -> -28",
183 | "strip 2 -> limit", "strip 3 -> comp knob -> 3.8"
184 | )
185 | # fmt: on
186 |
187 | with voicemeeterlib.api('potato') as vm:
188 | parser = Parser(vm)
189 | if args.i:
190 | interactive_mode(parser)
191 | return
192 |
193 | if res := parser.converter(cmds):
194 | print(res)
195 |
196 |
197 | if __name__ == '__main__':
198 | main()
199 |
--------------------------------------------------------------------------------
/examples/dsl/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name="dsl",
5 | description="dsl example",
6 | install_requires=["pyparsing"],
7 | )
8 |
--------------------------------------------------------------------------------
/examples/events/README.md:
--------------------------------------------------------------------------------
1 | # Events
2 |
3 | If you want to receive updates on certain events there are two routes you can take:
4 |
5 | - Register a class that implements an `on_update(self, event) -> None` method on the `{Remote}.subject` class.
6 | - Register callback functions/methods on the `{Remote}.subject` class, one for each type of update.
7 |
8 | Included are examples of both approaches.
9 |
--------------------------------------------------------------------------------
/examples/events/callbacks/README.md:
--------------------------------------------------------------------------------
1 | ## About
2 |
3 | This script demonstrates how to interact with the event thread/event object. It also demonstrates how to register event specific callbacks.
4 |
5 | By default the interface does not broadcast any events. So even though our callbacks are registered, and the event thread has been initiated, we won't receive updates.
6 |
7 | After five seconds the event object is used to subscribe to all events for a total of thirty seconds.
8 |
9 | Remember that events can also be unsubscribed to with `vm.event.remove()`. Callbacks can also be deregistered using vm.observer.remove().
10 |
11 | The same can be done without a context manager:
12 |
13 | ```python
14 | vm = voicemeeterlib.api(KIND_ID)
15 | vm.login()
16 | vm.observer.add(on_midi) # register an `on_midi` callback function
17 | vm.init_thread()
18 | vm.event.add("midi") # in this case we only subscribe to midi events.
19 | ...
20 | vm.end_thread()
21 | vm.logout()
22 | ```
23 |
24 | Once initialized, the event thread will continously run until end_thread() is called. Even if all events are unsubscribed to.
25 |
26 | ## Use
27 |
28 | Simply run the script and trigger events and you should see the output after 5 seconds. To trigger events do the following:
29 |
30 | - change GUI parameters to trigger pdirty
31 | - press any macrobutton to trigger mdirty
32 | - play audio through any bus to trigger ldirty
33 | - any midi input to trigger midi
34 |
--------------------------------------------------------------------------------
/examples/events/callbacks/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import time
3 |
4 | import voicemeeterlib
5 |
6 | logging.basicConfig(level=logging.INFO)
7 |
8 |
9 | class App:
10 | def __init__(self, vm):
11 | self._vm = vm
12 | # register the callbacks for each event
13 | self._vm.observer.add(
14 | [self.on_pdirty, self.on_mdirty, self.on_ldirty, self.on_midi]
15 | )
16 |
17 | def __enter__(self):
18 | self._vm.init_thread()
19 | return self
20 |
21 | def __exit__(self, exc_type, exc_value, traceback):
22 | self._vm.end_thread()
23 |
24 | def on_pdirty(self):
25 | print('pdirty!')
26 |
27 | def on_mdirty(self):
28 | print('mdirty!')
29 |
30 | def on_ldirty(self):
31 | for bus in self._vm.bus:
32 | if bus.levels.isdirty:
33 | print(bus, bus.levels.all)
34 |
35 | def on_midi(self):
36 | current = self._vm.midi.current
37 | print(f'Value of midi button {current} is {self._vm.midi.get(current)}')
38 |
39 |
40 | def main():
41 | KIND_ID = 'banana'
42 |
43 | with voicemeeterlib.api(KIND_ID) as vm:
44 | with App(vm):
45 | for i in range(5, 0, -1):
46 | print(f'events start in {i} seconds')
47 | time.sleep(1)
48 | vm.event.add(['pdirty', 'ldirty', 'midi', 'mdirty'])
49 | time.sleep(30)
50 |
51 |
52 | if __name__ == '__main__':
53 | main()
54 |
--------------------------------------------------------------------------------
/examples/events/observer/README.md:
--------------------------------------------------------------------------------
1 | ## About
2 |
3 | Registers a class as an observer and defines a callback.
4 |
5 | ## Use
6 |
7 | Run the script, then:
8 |
9 | - change GUI parameters to trigger pdirty
10 | - press any macrobutton to trigger mdirty
11 | - play audio through any bus to trigger ldirty
12 | - any midi input to trigger midi
13 |
14 | Pressing `` will exit.
15 |
--------------------------------------------------------------------------------
/examples/events/observer/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import voicemeeterlib
4 |
5 | logging.basicConfig(level=logging.INFO)
6 |
7 |
8 | class App:
9 | def __init__(self, vm):
10 | self._vm = vm
11 | # register your app as event observer
12 | self._vm.observer.add(self)
13 |
14 | def __str__(self):
15 | return type(self).__name__
16 |
17 | # define an 'on_update' callback function to receive event updates
18 | def on_update(self, event):
19 | if event == 'pdirty':
20 | print('pdirty!')
21 | elif event == 'mdirty':
22 | print('mdirty!')
23 | elif event == 'ldirty':
24 | for bus in self._vm.bus:
25 | if bus.levels.isdirty:
26 | print(bus, bus.levels.all)
27 | elif event == 'midi':
28 | current = self._vm.midi.current
29 | print(f'Value of midi button {current} is {self._vm.midi.get(current)}')
30 |
31 |
32 | def main():
33 | KIND_ID = 'banana'
34 |
35 | with voicemeeterlib.api(
36 | KIND_ID, **{k: True for k in ('pdirty', 'mdirty', 'ldirty', 'midi')}
37 | ) as vm:
38 | App(vm)
39 |
40 | while _ := input('Press to exit\n'):
41 | pass
42 |
43 |
44 | if __name__ == '__main__':
45 | main()
46 |
--------------------------------------------------------------------------------
/examples/gui/README.md:
--------------------------------------------------------------------------------
1 | ## About
2 |
3 | A single channel GUI demonstrating controls for the first virtual strip if Voicemeeter Banana.
4 |
5 | This example demonstrates (to an extent) two way communication.
6 | - Sending parameters values to the Voicemeeter driver.
7 | - Receiving level updates
8 |
9 | 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.
10 |
11 | ## Use
12 |
13 | Simply run the script and try the controls.
14 |
--------------------------------------------------------------------------------
/examples/gui/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import tkinter as tk
3 | from tkinter import ttk
4 |
5 | import voicemeeterlib
6 |
7 | logging.basicConfig(level=logging.DEBUG)
8 |
9 |
10 | class App(tk.Tk):
11 | INDEX = 3
12 |
13 | def __init__(self, vm):
14 | super().__init__()
15 | self._vm = vm
16 | self.title(f'{vm} - version {vm.version}')
17 | self._vm.observer.add(self.on_ldirty)
18 |
19 | # create widget variables
20 | self.button_var = tk.BooleanVar(value=vm.strip[self.INDEX].mute)
21 | self.slider_var = tk.DoubleVar(value=vm.strip[self.INDEX].gain)
22 | self.meter_var = tk.DoubleVar(value=self._get_level())
23 | self.gainlabel_var = tk.StringVar(value=self.slider_var.get())
24 |
25 | # initialize style table
26 | self.style = ttk.Style()
27 | self.style.theme_use('clam')
28 | self.style.configure(
29 | 'Mute.TButton',
30 | foreground='#cd5c5c' if vm.strip[self.INDEX].mute else '#5a5a5a',
31 | )
32 |
33 | # create labelframe and grid it onto the mainframe
34 | self.labelframe = tk.LabelFrame(self, text=self._vm.strip[self.INDEX].label)
35 | self.labelframe.grid(padx=1)
36 |
37 | # create slider and grid it onto the labelframe
38 | slider = ttk.Scale(
39 | self.labelframe,
40 | from_=12,
41 | to_=-60,
42 | orient='vertical',
43 | variable=self.slider_var,
44 | command=lambda arg: self.on_slider_move(arg),
45 | )
46 | slider.grid(
47 | column=0,
48 | row=0,
49 | )
50 | slider.bind('', self.on_button_double_click)
51 |
52 | # create level meter and grid it onto the labelframe
53 | level_meter = ttk.Progressbar(
54 | self.labelframe,
55 | orient='vertical',
56 | variable=self.meter_var,
57 | maximum=72,
58 | mode='determinate',
59 | )
60 | level_meter.grid(column=1, row=0)
61 |
62 | # create gainlabel and grid it onto the labelframe
63 | gainlabel = ttk.Label(self.labelframe, textvariable=self.gainlabel_var)
64 | gainlabel.grid(column=0, row=1, columnspan=2)
65 |
66 | # create button and grid it onto the labelframe
67 | button = ttk.Button(
68 | self.labelframe,
69 | text='Mute',
70 | style='Mute.TButton',
71 | command=lambda: self.on_button_press(),
72 | )
73 | button.grid(column=0, row=2, columnspan=2, padx=1, pady=2)
74 |
75 | # define callbacks
76 |
77 | def on_slider_move(self, *args):
78 | val = round(self.slider_var.get(), 1)
79 | self._vm.strip[self.INDEX].gain = val
80 | self.gainlabel_var.set(val)
81 |
82 | def on_button_press(self):
83 | self.button_var.set(not self.button_var.get())
84 | self._vm.strip[self.INDEX].mute = self.button_var.get()
85 | self.style.configure(
86 | 'Mute.TButton', foreground='#cd5c5c' if self.button_var.get() else '#5a5a5a'
87 | )
88 |
89 | def on_button_double_click(self, e):
90 | self.slider_var.set(0)
91 | self.gainlabel_var.set(0)
92 | self._vm.strip[self.INDEX].gain = 0
93 |
94 | def _get_level(self):
95 | val = max(self._vm.strip[self.INDEX].levels.postfader)
96 | return 0 if self.button_var.get() else 72 + val - 12
97 |
98 | def on_ldirty(self):
99 | self.meter_var.set(self._get_level())
100 |
101 |
102 | def main():
103 | with voicemeeterlib.api('banana', ldirty=True) as vm:
104 | app = App(vm)
105 | app.mainloop()
106 |
107 |
108 | if __name__ == '__main__':
109 | main()
110 |
--------------------------------------------------------------------------------
/examples/levels/README.md:
--------------------------------------------------------------------------------
1 | ## About
2 |
3 | The purpose of this script is to demonstrate:
4 |
5 | - use of the interface without a context manager.
6 | - retrieving level values for channels by polling (instead of receiving data as event)
7 | - use of the interface without the events thread running.
8 |
9 | ## Use
10 |
11 | Configured for potato version.
12 |
13 | Make sure you are playing audio into the first virtual strip and out of the first physical bus, both channels are unmuted and that you aren't monitoring another mixbus. Then run the script.
14 |
--------------------------------------------------------------------------------
/examples/levels/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import time
3 |
4 | import voicemeeterlib
5 |
6 | logging.basicConfig(level=logging.INFO)
7 |
8 |
9 | def main():
10 | KIND_ID = 'potato'
11 |
12 | vm = voicemeeterlib.api(KIND_ID)
13 | vm.login()
14 | for _ in range(500):
15 | print(
16 | '\n'.join(
17 | [
18 | f'{vm.strip[5]}: {vm.strip[5].levels.postmute}',
19 | f'{vm.bus[0]}: {vm.bus[0].levels.all}',
20 | ]
21 | )
22 | )
23 | time.sleep(0.033)
24 | vm.logout()
25 |
26 |
27 | if __name__ == '__main__':
28 | main()
29 |
--------------------------------------------------------------------------------
/examples/midi/README.md:
--------------------------------------------------------------------------------
1 | ## About/Requirements
2 |
3 | A simple demonstration showing how to use a midi controller with this API.
4 |
5 | This script was written for and tested with a Korg NanoKontrol2 configured in CC mode.
6 |
7 | In order to run this example script you will need to have setup Voicemeeter with a midi device in Menu->Midi Mapping.
8 |
9 | ## Use
10 |
11 | The script launches Voicemeeter Banana version and assumes that is the version being tested (if it was already launched)
12 |
13 | `get_info()` responds to any midi button press or midi slider movement and prints its' CC number and most recent value.
14 |
15 | `on_midi_press()` should enable trigger mode for macrobutton 0 if peak level value for strip 3 exceeds -40 and midi button 48 is pressed. On the NanoKontrol2 midi button 48 corresponds to the leftmost M button. You may need to disable any Keyboard Shortcut assignment first.
16 |
17 | For a clear illustration of what may be done fill in some commands in `Request for Button ON / Trigger IN` and `Request for Button OFF / Trigger OUT`.
18 |
19 | ## Resources
20 |
21 | If you want to know how to setup the NanoKontrol2 for CC mode check the following resources.
22 |
23 | - [Korg NanoKontrol2 Manual](https://www.korg.com/us/support/download/manual/0/159/1912/)
24 | - [CC Mode Info](https://i.korg.com/uploads/Support/nanoKONTROL2_PG_E1_634479709631760000.pdf)
25 |
--------------------------------------------------------------------------------
/examples/midi/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import voicemeeterlib
4 |
5 | logging.basicConfig(level=logging.DEBUG)
6 |
7 |
8 | class App:
9 | MIDI_BUTTON = 48 # leftmost M on korg nanokontrol2 in CC mode
10 | MACROBUTTON = 0
11 |
12 | def __init__(self, vm):
13 | self._vm = vm
14 | self._vm.observer.add(self.on_midi)
15 |
16 | def on_midi(self):
17 | if self.get_info() == self.MIDI_BUTTON:
18 | self.on_midi_press()
19 |
20 | def get_info(self):
21 | current = self._vm.midi.current
22 | print(f'Value of midi button {current} is {self._vm.midi.get(current)}')
23 | return current
24 |
25 | def on_midi_press(self):
26 | """if midi button 48 is pressed and strip 3 level max > -40, then set trigger for macrobutton 0"""
27 |
28 | if (
29 | self._vm.midi.get(self.MIDI_BUTTON) == 127
30 | and max(self._vm.strip[3].levels.postfader) > -40
31 | ):
32 | print(
33 | f'Strip 3 level max is greater than -40 and midi button {self.MIDI_BUTTON} is pressed'
34 | )
35 | self._vm.button[self.MACROBUTTON].trigger = True
36 | else:
37 | self._vm.button[self.MACROBUTTON].trigger = False
38 |
39 |
40 | def main():
41 | KIND_ID = 'banana'
42 |
43 | with voicemeeterlib.api(KIND_ID, midi=True) as vm:
44 | App(vm)
45 |
46 | while _ := input('Press to exit\n'):
47 | pass
48 |
49 |
50 | if __name__ == '__main__':
51 | main()
52 |
--------------------------------------------------------------------------------
/examples/obs/README.md:
--------------------------------------------------------------------------------
1 | ## Requirements
2 |
3 | - [OBS Studio](https://obsproject.com/)
4 | - [OBS Python SDK for Websocket v5](https://github.com/aatikturk/obsws-python)
5 |
6 | ## About
7 |
8 | A simple demonstration showing how to sync OBS scene switches to Voicemeeter states. The script assumes you have connection info saved in
9 | a config file named `config.toml` placed next to `__main__.py`. It also assumes you have scenes named `START` `BRB` `END` and `LIVE`.
10 |
11 | A valid `config.toml` file might look like this:
12 |
13 | ```toml
14 | [connection]
15 | host = "localhost"
16 | port = 4455
17 | password = "mystrongpass"
18 | ```
19 |
20 | Closing OBS will end the script.
21 |
22 | ## Notes
23 |
24 | In this example all but `voicemeeterlib.iremote` logs are filtered out. Log level set at DEBUG.
25 |
26 | For a similar Streamlabs Desktop example:
27 |
28 | [Streamlabs example](https://gist.github.com/onyx-and-iris/c864f07126eeae389b011dc49520a19b)
29 |
--------------------------------------------------------------------------------
/examples/obs/__main__.py:
--------------------------------------------------------------------------------
1 | import threading
2 | from logging import config
3 |
4 | import obsws_python as obsws
5 |
6 | import voicemeeterlib
7 |
8 | config.dictConfig(
9 | {
10 | 'version': 1,
11 | 'formatters': {
12 | 'standard': {
13 | 'format': '%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s'
14 | }
15 | },
16 | 'handlers': {
17 | 'stream': {
18 | 'level': 'DEBUG',
19 | 'class': 'logging.StreamHandler',
20 | 'formatter': 'standard',
21 | }
22 | },
23 | 'loggers': {
24 | 'voicemeeterlib.iremote': {
25 | 'handlers': ['stream'],
26 | 'level': 'DEBUG',
27 | 'propagate': False,
28 | }
29 | },
30 | 'root': {'handlers': ['stream'], 'level': 'WARNING'},
31 | }
32 | )
33 |
34 |
35 | class MyClient:
36 | def __init__(self, vm, stop_event):
37 | self._vm = vm
38 | self._stop_event = stop_event
39 | self._client = obsws.EventClient()
40 | self._client.callback.register(
41 | (
42 | self.on_current_program_scene_changed,
43 | self.on_exit_started,
44 | )
45 | )
46 |
47 | def __enter__(self):
48 | return self
49 |
50 | def __exit__(self, exc_type, exc_value, exc_traceback):
51 | self._client.disconnect()
52 |
53 | def on_start(self):
54 | self._vm.strip[0].mute = True
55 | self._vm.strip[1].B1 = True
56 | self._vm.strip[2].B2 = True
57 |
58 | def on_brb(self):
59 | self._vm.strip[7].fadeto(0, 500)
60 | self._vm.bus[0].mute = True
61 |
62 | def on_end(self):
63 | self._vm.apply(
64 | {
65 | 'strip-0': {'mute': True, 'comp': {'ratio': 4.3}},
66 | 'strip-1': {'mute': True, 'B1': False, 'gate': {'attack': 2.3}},
67 | 'strip-2': {'mute': True, 'B1': False},
68 | 'vban-in-0': {'on': False},
69 | }
70 | )
71 |
72 | def on_live(self):
73 | self._vm.strip[0].mute = False
74 | self._vm.strip[7].fadeto(-6, 500)
75 | self._vm.strip[7].A3 = True
76 | self._vm.vban.instream[0].on = True
77 |
78 | def on_current_program_scene_changed(self, data):
79 | scene = data.scene_name
80 | print(f'Switched to scene {scene}')
81 | match scene:
82 | case 'START':
83 | self.on_start()
84 | case 'BRB':
85 | self.on_brb()
86 | case 'END':
87 | self.on_end()
88 | case 'LIVE':
89 | self.on_live()
90 |
91 | def on_exit_started(self, _):
92 | self._stop_event.set()
93 |
94 |
95 | def main():
96 | KIND_ID = 'potato'
97 |
98 | with voicemeeterlib.api(KIND_ID) as vm:
99 | stop_event = threading.Event()
100 |
101 | with MyClient(vm, stop_event):
102 | stop_event.wait()
103 |
104 |
105 | if __name__ == '__main__':
106 | main()
107 |
--------------------------------------------------------------------------------
/examples/obs/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name="obs",
5 | description="OBS Example",
6 | install_requires=["obsws-python"],
7 | )
8 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
2 |
3 | [[package]]
4 | name = "cachetools"
5 | version = "5.5.0"
6 | description = "Extensible memoizing collections and decorators"
7 | optional = false
8 | python-versions = ">=3.7"
9 | groups = ["dev"]
10 | files = [
11 | {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"},
12 | {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"},
13 | ]
14 |
15 | [[package]]
16 | name = "chardet"
17 | version = "5.2.0"
18 | description = "Universal encoding detector for Python 3"
19 | optional = false
20 | python-versions = ">=3.7"
21 | groups = ["dev"]
22 | files = [
23 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
24 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
25 | ]
26 |
27 | [[package]]
28 | name = "colorama"
29 | version = "0.4.6"
30 | description = "Cross-platform colored terminal text."
31 | optional = false
32 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
33 | groups = ["dev"]
34 | files = [
35 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
36 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
37 | ]
38 |
39 | [[package]]
40 | name = "distlib"
41 | version = "0.3.9"
42 | description = "Distribution utilities"
43 | optional = false
44 | python-versions = "*"
45 | groups = ["dev"]
46 | files = [
47 | {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
48 | {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
49 | ]
50 |
51 | [[package]]
52 | name = "exceptiongroup"
53 | version = "1.2.2"
54 | description = "Backport of PEP 654 (exception groups)"
55 | optional = false
56 | python-versions = ">=3.7"
57 | groups = ["dev"]
58 | markers = "python_version < \"3.11\""
59 | files = [
60 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
61 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
62 | ]
63 |
64 | [package.extras]
65 | test = ["pytest (>=6)"]
66 |
67 | [[package]]
68 | name = "filelock"
69 | version = "3.16.1"
70 | description = "A platform independent file lock."
71 | optional = false
72 | python-versions = ">=3.8"
73 | groups = ["dev"]
74 | files = [
75 | {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
76 | {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
77 | ]
78 |
79 | [package.extras]
80 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
81 | 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)"]
82 | typing = ["typing-extensions (>=4.12.2)"]
83 |
84 | [[package]]
85 | name = "iniconfig"
86 | version = "2.0.0"
87 | description = "brain-dead simple config-ini parsing"
88 | optional = false
89 | python-versions = ">=3.7"
90 | groups = ["dev"]
91 | files = [
92 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
93 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
94 | ]
95 |
96 | [[package]]
97 | name = "packaging"
98 | version = "24.2"
99 | description = "Core utilities for Python packages"
100 | optional = false
101 | python-versions = ">=3.8"
102 | groups = ["dev"]
103 | files = [
104 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
105 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
106 | ]
107 |
108 | [[package]]
109 | name = "platformdirs"
110 | version = "4.3.6"
111 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
112 | optional = false
113 | python-versions = ">=3.8"
114 | groups = ["dev"]
115 | files = [
116 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
117 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
118 | ]
119 |
120 | [package.extras]
121 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
122 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
123 | type = ["mypy (>=1.11.2)"]
124 |
125 | [[package]]
126 | name = "pluggy"
127 | version = "1.5.0"
128 | description = "plugin and hook calling mechanisms for python"
129 | optional = false
130 | python-versions = ">=3.8"
131 | groups = ["dev"]
132 | files = [
133 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
134 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
135 | ]
136 |
137 | [package.extras]
138 | dev = ["pre-commit", "tox"]
139 | testing = ["pytest", "pytest-benchmark"]
140 |
141 | [[package]]
142 | name = "pyenv-inspect"
143 | version = "0.4.0"
144 | description = "An auxiliary library for the virtualenv-pyenv and tox-pyenv-redux plugins"
145 | optional = false
146 | python-versions = ">=3.8"
147 | groups = ["dev"]
148 | files = [
149 | {file = "pyenv-inspect-0.4.0.tar.gz", hash = "sha256:ec429d1d81b67ab0b08a0408414722a79d24fd1845a5b264267e44e19d8d60f0"},
150 | {file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"},
151 | ]
152 |
153 | [[package]]
154 | name = "pyproject-api"
155 | version = "1.8.0"
156 | description = "API to interact with the python pyproject.toml based projects"
157 | optional = false
158 | python-versions = ">=3.8"
159 | groups = ["dev"]
160 | files = [
161 | {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"},
162 | {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"},
163 | ]
164 |
165 | [package.dependencies]
166 | packaging = ">=24.1"
167 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
168 |
169 | [package.extras]
170 | docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
171 | testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
172 |
173 | [[package]]
174 | name = "pytest"
175 | version = "8.3.4"
176 | description = "pytest: simple powerful testing with Python"
177 | optional = false
178 | python-versions = ">=3.8"
179 | groups = ["dev"]
180 | files = [
181 | {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
182 | {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
183 | ]
184 |
185 | [package.dependencies]
186 | colorama = {version = "*", markers = "sys_platform == \"win32\""}
187 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
188 | iniconfig = "*"
189 | packaging = "*"
190 | pluggy = ">=1.5,<2"
191 | tomli = {version = ">=1", markers = "python_version < \"3.11\""}
192 |
193 | [package.extras]
194 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
195 |
196 | [[package]]
197 | name = "pytest-randomly"
198 | version = "3.16.0"
199 | description = "Pytest plugin to randomly order tests and control random.seed."
200 | optional = false
201 | python-versions = ">=3.9"
202 | groups = ["dev"]
203 | files = [
204 | {file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"},
205 | {file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"},
206 | ]
207 |
208 | [package.dependencies]
209 | pytest = "*"
210 |
211 | [[package]]
212 | name = "ruff"
213 | version = "0.8.6"
214 | description = "An extremely fast Python linter and code formatter, written in Rust."
215 | optional = false
216 | python-versions = ">=3.7"
217 | groups = ["dev"]
218 | files = [
219 | {file = "ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3"},
220 | {file = "ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1"},
221 | {file = "ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807"},
222 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25"},
223 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d"},
224 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75"},
225 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315"},
226 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188"},
227 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf"},
228 | {file = "ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117"},
229 | {file = "ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe"},
230 | {file = "ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d"},
231 | {file = "ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a"},
232 | {file = "ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76"},
233 | {file = "ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764"},
234 | {file = "ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905"},
235 | {file = "ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162"},
236 | {file = "ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5"},
237 | ]
238 |
239 | [[package]]
240 | name = "tomli"
241 | version = "2.2.1"
242 | description = "A lil' TOML parser"
243 | optional = false
244 | python-versions = ">=3.8"
245 | groups = ["main", "dev"]
246 | markers = "python_version < \"3.11\""
247 | files = [
248 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
249 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
250 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
251 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
252 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
253 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
254 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
255 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
256 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
257 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
258 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
259 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
260 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
261 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
262 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
263 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
264 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
265 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
266 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
267 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
268 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
269 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
270 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
271 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
272 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
273 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
274 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
275 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
276 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
277 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
278 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
279 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
280 | ]
281 |
282 | [[package]]
283 | name = "tox"
284 | version = "4.23.2"
285 | description = "tox is a generic virtualenv management and test command line tool"
286 | optional = false
287 | python-versions = ">=3.8"
288 | groups = ["dev"]
289 | files = [
290 | {file = "tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38"},
291 | {file = "tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c"},
292 | ]
293 |
294 | [package.dependencies]
295 | cachetools = ">=5.5"
296 | chardet = ">=5.2"
297 | colorama = ">=0.4.6"
298 | filelock = ">=3.16.1"
299 | packaging = ">=24.1"
300 | platformdirs = ">=4.3.6"
301 | pluggy = ">=1.5"
302 | pyproject-api = ">=1.8"
303 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
304 | typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
305 | virtualenv = ">=20.26.6"
306 |
307 | [package.extras]
308 | test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
309 |
310 | [[package]]
311 | name = "typing-extensions"
312 | version = "4.12.2"
313 | description = "Backported and Experimental Type Hints for Python 3.8+"
314 | optional = false
315 | python-versions = ">=3.8"
316 | groups = ["dev"]
317 | markers = "python_version < \"3.11\""
318 | files = [
319 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
320 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
321 | ]
322 |
323 | [[package]]
324 | name = "virtualenv"
325 | version = "20.28.1"
326 | description = "Virtual Python Environment builder"
327 | optional = false
328 | python-versions = ">=3.8"
329 | groups = ["dev"]
330 | files = [
331 | {file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"},
332 | {file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"},
333 | ]
334 |
335 | [package.dependencies]
336 | distlib = ">=0.3.7,<1"
337 | filelock = ">=3.12.2,<4"
338 | platformdirs = ">=3.9.1,<5"
339 |
340 | [package.extras]
341 | 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)"]
342 | 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)"]
343 |
344 | [[package]]
345 | name = "virtualenv-pyenv"
346 | version = "0.5.0"
347 | description = "A virtualenv Python discovery plugin for pyenv-installed interpreters"
348 | optional = false
349 | python-versions = ">=3.8"
350 | groups = ["dev"]
351 | files = [
352 | {file = "virtualenv-pyenv-0.5.0.tar.gz", hash = "sha256:7b0e5fe3dfbdf484f4cf9b01e1f98111e398db6942237910f666356e6293597f"},
353 | {file = "virtualenv_pyenv-0.5.0-py3-none-any.whl", hash = "sha256:21750247e36c55b3c547cfdeb08f51a3867fe7129922991a4f9c96980c0a4a5d"},
354 | ]
355 |
356 | [package.dependencies]
357 | pyenv-inspect = ">=0.4,<0.5"
358 | virtualenv = "*"
359 |
360 | [metadata]
361 | lock-version = "2.1"
362 | python-versions = ">=3.10"
363 | content-hash = "6339967c3f6cad8e4db7047ef3d12a5b059a279d0f7c98515c961477680bab8f"
364 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "voicemeeter-api"
3 | version = "2.6.1"
4 | description = "A Python wrapper for the Voiceemeter API"
5 | authors = [
6 | {name = "Onyx and Iris",email = "code@onyxandiris.online"}
7 | ]
8 | license = {text = "MIT"}
9 | readme = "README.md"
10 | requires-python = ">=3.10"
11 | dependencies = [
12 | "tomli (>=2.0.1,<3.0) ; python_version < '3.11'",
13 | ]
14 |
15 | [tool.poetry]
16 | packages = [{ include = "voicemeeterlib" }]
17 |
18 | [tool.poetry.requires-plugins]
19 | poethepoet = "^0.32.1"
20 |
21 | [tool.poetry.group.dev.dependencies]
22 | pytest = "^8.3.4"
23 | pytest-randomly = "^3.16.0"
24 | ruff = "^0.8.6"
25 | tox = "^4.23.2"
26 | virtualenv-pyenv = "^0.5.0"
27 |
28 | [build-system]
29 | requires = ["poetry-core>=2.0.0,<3.0.0"]
30 | build-backend = "poetry.core.masonry.api"
31 |
32 | [tool.poe.tasks]
33 | dsl.script = "scripts:ex_dsl"
34 | callbacks.script = "scripts:ex_callbacks"
35 | gui.script = "scripts:ex_gui"
36 | levels.script = "scripts:ex_levels"
37 | midi.script = "scripts:ex_midi"
38 | obs.script = "scripts:ex_obs"
39 | observer.script = "scripts:ex_observer"
40 | test-basic.script = "scripts:test_basic"
41 | test-banana.script = "scripts:test_banana"
42 | test-potato.script = "scripts:test_potato"
43 | test-all.script = "scripts:test_all"
44 | generate-badges.script = "scripts:generate_badges"
45 |
46 |
47 | [tool.ruff]
48 | exclude = [
49 | ".bzr",
50 | ".direnv",
51 | ".eggs",
52 | ".git",
53 | ".git-rewrite",
54 | ".hg",
55 | ".mypy_cache",
56 | ".nox",
57 | ".pants.d",
58 | ".pytype",
59 | ".ruff_cache",
60 | ".svn",
61 | ".tox",
62 | ".venv",
63 | "__pypackages__",
64 | "_build",
65 | "buck-out",
66 | "build",
67 | "dist",
68 | "node_modules",
69 | "venv",
70 | ]
71 |
72 | # Same as Black.
73 | line-length = 88
74 | indent-width = 4
75 |
76 | # Assume Python 3.10
77 | target-version = "py310"
78 |
79 | [tool.ruff.lint]
80 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
81 | # Enable flake8-errmsg (EM) warnings.
82 | # Enable flake8-bugbear (B) warnings.
83 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
84 | # McCabe complexity (`C901`) by default.
85 | select = ["E4", "E7", "E9", "EM", "F", "B"]
86 | ignore = []
87 |
88 | # Allow fix for all enabled rules (when `--fix`) is provided.
89 | fixable = ["ALL"]
90 | unfixable = ["B"]
91 |
92 | # Allow unused variables when underscore-prefixed.
93 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
94 |
95 |
96 | [tool.ruff.format]
97 | # Unlike Black, use single quotes for strings.
98 | quote-style = "single"
99 |
100 | # Like Black, indent with spaces, rather than tabs.
101 | indent-style = "space"
102 |
103 | # Like Black, respect magic trailing commas.
104 | skip-magic-trailing-comma = false
105 |
106 | # Like Black, automatically detect the appropriate line ending.
107 | line-ending = "auto"
108 |
109 | # Enable auto-formatting of code examples in docstrings. Markdown,
110 | # reStructuredText code/literal blocks and doctests are all supported.
111 | #
112 | # This is currently disabled by default, but it is planned for this
113 | # to be opt-out in the future.
114 | docstring-code-format = false
115 |
116 | # Set the line length limit used when formatting code snippets in
117 | # docstrings.
118 | #
119 | # This only has an effect when the `docstring-code-format` setting is
120 | # enabled.
121 | docstring-code-line-length = "dynamic"
122 |
123 | [tool.ruff.lint.mccabe]
124 | max-complexity = 10
125 |
126 | [tool.ruff.lint.per-file-ignores]
127 | "__init__.py" = [
128 | "E402",
129 | "F401",
130 | ]
131 |
--------------------------------------------------------------------------------
/scripts.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import sys
4 | from pathlib import Path
5 |
6 |
7 | def ex_dsl():
8 | subprocess.run(['tox', 'r', '-e', 'dsl'])
9 |
10 |
11 | def ex_callbacks():
12 | scriptpath = Path.cwd() / 'examples' / 'events' / 'callbacks' / '.'
13 | subprocess.run([sys.executable, str(scriptpath)])
14 |
15 |
16 | def ex_gui():
17 | scriptpath = Path.cwd() / 'examples' / 'gui' / '.'
18 | subprocess.run([sys.executable, str(scriptpath)])
19 |
20 |
21 | def ex_levels():
22 | scriptpath = Path.cwd() / 'examples' / 'levels' / '.'
23 | subprocess.run([sys.executable, str(scriptpath)])
24 |
25 |
26 | def ex_midi():
27 | scriptpath = Path.cwd() / 'examples' / 'midi' / '.'
28 | subprocess.run([sys.executable, str(scriptpath)])
29 |
30 |
31 | def ex_obs():
32 | subprocess.run(['tox', 'r', '-e', 'obs'])
33 |
34 |
35 | def ex_observer():
36 | scriptpath = Path.cwd() / 'examples' / 'events' / 'observer' / '.'
37 | subprocess.run([sys.executable, str(scriptpath)])
38 |
39 |
40 | def test_basic():
41 | subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'basic'})
42 |
43 |
44 | def test_banana():
45 | subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'banana'})
46 |
47 |
48 | def test_potato():
49 | subprocess.run(['tox'], env=os.environ.copy() | {'KIND': 'potato'})
50 |
51 |
52 | def test_all():
53 | steps = [test_basic, test_banana, test_potato]
54 | for step in steps:
55 | step()
56 |
57 |
58 | def generate_badges():
59 | for kind in ['basic', 'banana', 'potato']:
60 | subprocess.run(
61 | ['tox', 'r', '-e', 'genbadge'], env=os.environ.copy() | {'KIND': kind}
62 | )
63 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import random
3 | import sys
4 | from dataclasses import dataclass
5 |
6 | import voicemeeterlib
7 | from voicemeeterlib.kinds import KindId
8 | from voicemeeterlib.kinds import request_kind_map as kindmap
9 |
10 |
11 | @dataclass
12 | class Data:
13 | """bounds data to map tests to a kind"""
14 |
15 | name: str
16 | phys_in: int
17 | virt_in: int
18 | phys_out: int
19 | virt_out: int
20 | vban_in: int
21 | vban_out: int
22 | button_lower: int
23 | button_upper: int
24 | asio_in: int
25 | asio_out: int
26 | insert_lower: int
27 | insert_higher: int
28 |
29 | @property
30 | def channels(self):
31 | return (2 * self.phys_in) + (8 * self.virt_in)
32 |
33 |
34 | # get KIND from environment, if not set default to potato
35 | KIND_ID = os.environ.get('KIND', 'potato')
36 | vm = voicemeeterlib.api(KIND_ID)
37 | kind = kindmap(KIND_ID)
38 |
39 | data = Data(
40 | name=kind.name,
41 | phys_in=kind.ins[0] - 1,
42 | virt_in=kind.ins[0] + kind.ins[-1] - 1,
43 | phys_out=kind.outs[0] - 1,
44 | virt_out=kind.outs[0] + kind.outs[-1] - 1,
45 | vban_in=kind.vban[0] - 1,
46 | vban_out=kind.vban[-1] - 1,
47 | button_lower=0,
48 | button_upper=79,
49 | asio_in=kind.asio[0] - 1,
50 | asio_out=kind.asio[-1] - 1,
51 | insert_lower=0,
52 | insert_higher=kind.insert - 1,
53 | )
54 |
55 |
56 | def setup_module():
57 | print(f'\nRunning tests for kind [{data.name}]\n', file=sys.stdout)
58 | vm.login()
59 | vm.command.reset()
60 |
61 |
62 | def teardown_module():
63 | vm.logout()
64 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | def pytest_addoption(parser):
2 | parser.addoption(
3 | "--run-slow",
4 | action="store_true",
5 | default=False,
6 | help="Run slow tests",
7 | )
8 |
--------------------------------------------------------------------------------
/tests/reports/badge-banana.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/reports/badge-basic.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/reports/badge-potato.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/test_configs.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests import data, vm
4 |
5 |
6 | class TestUserConfigs:
7 | __test__ = True
8 |
9 | """example config vm"""
10 |
11 | @classmethod
12 | def setup_class(cls):
13 | vm.apply_config("example")
14 |
15 | def test_it_tests_vm_config_string(self):
16 | assert "PhysStrip" in vm.strip[data.phys_in].label
17 | assert "VirtStrip" in vm.strip[data.virt_in].label
18 | assert "PhysBus" in vm.bus[data.phys_out].label
19 | assert "VirtBus" in vm.bus[data.virt_out].label
20 |
21 | def test_it_tests_vm_config_bool(self):
22 | assert vm.strip[0].A1 == True
23 |
24 | @pytest.mark.skipif(
25 | data.name != "potato",
26 | reason="Skip test if kind is not potato",
27 | )
28 | def test_it_tests_vm_config_bool_strip_eq_on(self):
29 | assert vm.strip[data.phys_in].eq.on == True
30 |
31 | @pytest.mark.skipif(
32 | data.name != "banana",
33 | reason="Skip test if kind is not banana",
34 | )
35 | def test_it_tests_vm_config_bool_bus_eq_ab(self):
36 | assert vm.bus[data.phys_out].eq.ab == True
37 |
38 | @pytest.mark.skipif(
39 | "not config.getoption('--run-slow')",
40 | reason="Only run when --run-slow is given",
41 | )
42 | def test_it_tests_vm_config_busmode(self):
43 | assert vm.bus[data.phys_out].mode.get() == "composite"
44 |
45 | def test_it_tests_vm_config_bass_med_high(self):
46 | assert vm.strip[data.virt_in].bass == -3.2
47 | assert vm.strip[data.virt_in].mid == 1.5
48 | assert vm.strip[data.virt_in].high == 2.1
49 |
--------------------------------------------------------------------------------
/tests/test_errors.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import pytest
4 |
5 | import voicemeeterlib
6 | from tests import data, vm
7 |
8 |
9 | class TestErrors:
10 | __test__ = True
11 |
12 | def test_it_tests_an_unknown_kind(self):
13 | with pytest.raises(
14 | voicemeeterlib.error.VMError,
15 | match="Unknown Voicemeeter kind 'unknown_kind'",
16 | ):
17 | voicemeeterlib.api("unknown_kind")
18 |
19 | def test_it_tests_an_unknown_parameter(self):
20 | with pytest.raises(
21 | voicemeeterlib.error.CAPIError,
22 | match="VBVMR_SetParameterFloat returned -3",
23 | ) as exc_info:
24 | vm.set("unknown.parameter", 1)
25 |
26 | e = exc_info.value
27 | assert e.code == -3
28 | assert e.fn_name == "VBVMR_SetParameterFloat"
29 |
30 | def test_it_tests_an_unknown_config_name(self):
31 | EXPECTED_MSG = (
32 | "No config with name 'unknown' is loaded into memory",
33 | f"Known configs: {list(vm.configs.keys())}",
34 | )
35 |
36 | with pytest.raises(
37 | voicemeeterlib.error.VMError, match=re.escape("\n".join(EXPECTED_MSG))
38 | ):
39 | vm.apply_config("unknown")
40 |
41 | def test_it_tests_an_invalid_config_key(self):
42 | CONFIG = {
43 | "strip-0": {"A1": True, "B1": True, "gain": -6.0},
44 | "bus-0": {"mute": True, "eq": {"on": True}},
45 | "unknown-0": {"state": True},
46 | "vban-out-1": {"name": "streamname"},
47 | }
48 | with pytest.raises(ValueError, match="invalid config key 'unknown-0'"):
49 | vm.apply(CONFIG)
50 |
--------------------------------------------------------------------------------
/tests/test_factory.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests import data, vm
4 |
5 |
6 | class TestRemoteFactories:
7 | __test__ = True
8 |
9 | @pytest.mark.skipif(
10 | data.name != "basic",
11 | reason="Skip test if kind is not basic",
12 | )
13 | def test_it_tests_vm_remote_attrs_for_basic(self):
14 | assert hasattr(vm, "strip")
15 | assert hasattr(vm, "bus")
16 | assert hasattr(vm, "command")
17 | assert hasattr(vm, "button")
18 | assert hasattr(vm, "vban")
19 | assert hasattr(vm, "device")
20 | assert hasattr(vm, "option")
21 |
22 | assert len(vm.strip) == 3
23 | assert len(vm.bus) == 2
24 | assert len(vm.button) == 80
25 | assert len(vm.vban.instream) == 6 and len(vm.vban.outstream) == 5
26 |
27 | @pytest.mark.skipif(
28 | data.name != "banana",
29 | reason="Skip test if kind is not banana",
30 | )
31 | def test_it_tests_vm_remote_attrs_for_banana(self):
32 | assert hasattr(vm, "strip")
33 | assert hasattr(vm, "bus")
34 | assert hasattr(vm, "command")
35 | assert hasattr(vm, "button")
36 | assert hasattr(vm, "vban")
37 | assert hasattr(vm, "device")
38 | assert hasattr(vm, "option")
39 | assert hasattr(vm, "recorder")
40 | assert hasattr(vm, "patch")
41 |
42 | assert len(vm.strip) == 5
43 | assert len(vm.bus) == 5
44 | assert len(vm.button) == 80
45 | assert len(vm.vban.instream) == 10 and len(vm.vban.outstream) == 9
46 |
47 | @pytest.mark.skipif(
48 | data.name != "potato",
49 | reason="Skip test if kind is not potato",
50 | )
51 | def test_it_tests_vm_remote_attrs_for_potato(self):
52 | assert hasattr(vm, "strip")
53 | assert hasattr(vm, "bus")
54 | assert hasattr(vm, "command")
55 | assert hasattr(vm, "button")
56 | assert hasattr(vm, "vban")
57 | assert hasattr(vm, "device")
58 | assert hasattr(vm, "option")
59 | assert hasattr(vm, "recorder")
60 | assert hasattr(vm, "patch")
61 | assert hasattr(vm, "fx")
62 |
63 | assert len(vm.strip) == 8
64 | assert len(vm.bus) == 8
65 | assert len(vm.button) == 80
66 | assert len(vm.vban.instream) == 10 and len(vm.vban.outstream) == 9
67 |
--------------------------------------------------------------------------------
/tests/test_higher.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests import data, vm
4 |
5 |
6 | @pytest.mark.parametrize("value", [False, True])
7 | class TestSetAndGetBoolHigher:
8 | __test__ = True
9 |
10 | """strip tests, physical and virtual"""
11 |
12 | @pytest.mark.parametrize(
13 | "index,param",
14 | [
15 | (data.phys_in, "mute"),
16 | (data.phys_in, "mono"),
17 | (data.virt_in, "mc"),
18 | (data.virt_in, "mono"),
19 | ],
20 | )
21 | def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
22 | setattr(vm.strip[index], param, value)
23 | assert getattr(vm.strip[index], param) == value
24 |
25 | """ strip EQ tests, physical """
26 |
27 | @pytest.mark.skipif(
28 | data.name != "potato",
29 | reason="Skip test if kind is not potato",
30 | )
31 | @pytest.mark.parametrize(
32 | "index,param",
33 | [
34 | (data.phys_in, "on"),
35 | (data.phys_in, "ab"),
36 | ],
37 | )
38 | def test_it_sets_and_gets_strip_eq_bool_params(self, index, param, value):
39 | assert hasattr(vm.strip[index].eq, param)
40 | setattr(vm.strip[index].eq, param, value)
41 | assert getattr(vm.strip[index].eq, param) == value
42 |
43 | """ bus tests, physical and virtual """
44 |
45 | @pytest.mark.parametrize(
46 | "index,param",
47 | [
48 | (data.phys_out, "mute"),
49 | (data.virt_out, "sel"),
50 | ],
51 | )
52 | def test_it_sets_and_gets_bus_bool_params(self, index, param, value):
53 | assert hasattr(vm.bus[index], param)
54 | setattr(vm.bus[index], param, value)
55 | assert getattr(vm.bus[index], param) == value
56 |
57 | """ bus EQ tests, physical and virtual """
58 |
59 | @pytest.mark.parametrize(
60 | "index,param",
61 | [
62 | (data.phys_out, "on"),
63 | (data.virt_out, "ab"),
64 | ],
65 | )
66 | def test_it_sets_and_gets_bus_eq_bool_params(self, index, param, value):
67 | assert hasattr(vm.bus[index].eq, param)
68 | setattr(vm.bus[index].eq, param, value)
69 | assert getattr(vm.bus[index].eq, param) == value
70 |
71 | """ bus modes tests, physical and virtual """
72 |
73 | @pytest.mark.skipif(
74 | data.name != "basic",
75 | reason="Skip test if kind is not basic",
76 | )
77 | @pytest.mark.parametrize(
78 | "index,param",
79 | [
80 | (data.phys_out, "normal"),
81 | (data.phys_out, "amix"),
82 | (data.virt_out, "normal"),
83 | (data.virt_out, "composite"),
84 | ],
85 | )
86 | def test_it_sets_and_gets_busmode_basic_bool_params(self, index, param, value):
87 | setattr(vm.bus[index].mode, param, value)
88 | assert getattr(vm.bus[index].mode, param) == value
89 |
90 | @pytest.mark.skipif(
91 | data.name == "basic",
92 | reason="Skip test if kind is basic",
93 | )
94 | @pytest.mark.parametrize(
95 | "index,param",
96 | [
97 | (data.phys_out, "normal"),
98 | (data.phys_out, "amix"),
99 | (data.phys_out, "rearonly"),
100 | (data.virt_out, "normal"),
101 | (data.virt_out, "upmix41"),
102 | (data.virt_out, "composite"),
103 | ],
104 | )
105 | def test_it_sets_and_gets_busmode_bool_params(self, index, param, value):
106 | setattr(vm.bus[index].mode, param, value)
107 | assert getattr(vm.bus[index].mode, param) == value
108 |
109 | """ macrobutton tests """
110 |
111 | @pytest.mark.parametrize(
112 | "index,param",
113 | [(data.button_lower, "state"), (data.button_upper, "trigger")],
114 | )
115 | def test_it_sets_and_gets_macrobutton_bool_params(self, index, param, value):
116 | setattr(vm.button[index], param, value)
117 | assert getattr(vm.button[index], param) == value
118 |
119 | """ vban instream tests """
120 |
121 | @pytest.mark.parametrize(
122 | "index,param",
123 | [(data.vban_in, "on")],
124 | )
125 | def test_it_sets_and_gets_vban_instream_bool_params(self, index, param, value):
126 | setattr(vm.vban.instream[index], param, value)
127 | assert getattr(vm.vban.instream[index], param) == value
128 |
129 | """ vban outstream tests """
130 |
131 | @pytest.mark.parametrize(
132 | "index,param",
133 | [(data.vban_out, "on")],
134 | )
135 | def test_it_sets_and_gets_vban_outstream_bool_params(self, index, param, value):
136 | setattr(vm.vban.outstream[index], param, value)
137 | assert getattr(vm.vban.outstream[index], param) == value
138 |
139 | """ command tests """
140 |
141 | @pytest.mark.parametrize(
142 | "param",
143 | [("lock")],
144 | )
145 | def test_it_sets_command_bool_params(self, param, value):
146 | setattr(vm.command, param, value)
147 |
148 | """ recorder tests """
149 |
150 | @pytest.mark.skipif(
151 | data.name == "basic",
152 | reason="Skip test if kind is basic",
153 | )
154 | @pytest.mark.parametrize(
155 | "param",
156 | [("A1"), ("B2")],
157 | )
158 | def test_it_sets_and_gets_recorder_bool_params(self, param, value):
159 | assert hasattr(vm.recorder, param)
160 | setattr(vm.recorder, param, value)
161 | assert getattr(vm.recorder, param) == value
162 |
163 | @pytest.mark.skipif(
164 | data.name == "basic",
165 | reason="Skip test if kind is basic",
166 | )
167 | @pytest.mark.parametrize(
168 | "param",
169 | [("loop")],
170 | )
171 | def test_it_sets_recorder_bool_params(self, param, value):
172 | assert hasattr(vm.recorder, param)
173 | setattr(vm.recorder, param, value)
174 | assert getattr(vm.recorder, param) == value
175 |
176 | """ recoder.mode tests """
177 |
178 | @pytest.mark.skipif(
179 | data.name == "basic",
180 | reason="Skip test if kind is basic",
181 | )
182 | @pytest.mark.parametrize(
183 | "param",
184 | [("loop"), ("recbus")],
185 | )
186 | def test_it_sets_recorder_mode_bool_params(self, param, value):
187 | assert hasattr(vm.recorder.mode, param)
188 | setattr(vm.recorder.mode, param, value)
189 | assert getattr(vm.recorder.mode, param) == value
190 |
191 | """ recorder.armstrip """
192 |
193 | @pytest.mark.skipif(
194 | data.name == "basic",
195 | reason="Skip test if kind is basic",
196 | )
197 | @pytest.mark.parametrize(
198 | "index",
199 | [
200 | (data.phys_out),
201 | (data.virt_out),
202 | ],
203 | )
204 | def test_it_sets_recorder_armstrip_bool_params(self, index, value):
205 | vm.recorder.armstrip[index].set(value)
206 |
207 | """ recorder.armbus """
208 |
209 | @pytest.mark.skipif(
210 | data.name == "basic",
211 | reason="Skip test if kind is basic",
212 | )
213 | @pytest.mark.parametrize(
214 | "index",
215 | [
216 | (data.phys_out),
217 | (data.virt_out),
218 | ],
219 | )
220 | def test_it_sets_recorder_armbus_bool_params(self, index, value):
221 | vm.recorder.armbus[index].set(True)
222 |
223 | """ fx tests """
224 |
225 | @pytest.mark.skipif(
226 | data.name != "potato",
227 | reason="Skip test if kind is not potato",
228 | )
229 | @pytest.mark.parametrize(
230 | "param",
231 | [("reverb"), ("reverb_ab"), ("delay"), ("delay_ab")],
232 | )
233 | def test_it_sets_and_gets_fx_bool_params(self, param, value):
234 | setattr(vm.fx, param, value)
235 | assert getattr(vm.fx, param) == value
236 |
237 | """ patch tests """
238 |
239 | @pytest.mark.skipif(
240 | data.name == "basic",
241 | reason="Skip test if kind is basic",
242 | )
243 | @pytest.mark.parametrize(
244 | "param",
245 | [("postfadercomposite")],
246 | )
247 | def test_it_sets_and_gets_patch_bool_params(self, param, value):
248 | setattr(vm.patch, param, value)
249 | assert getattr(vm.patch, param) == value
250 |
251 | """ patch.insert tests """
252 |
253 | @pytest.mark.skipif(
254 | data.name == "basic",
255 | reason="Skip test if kind is basic",
256 | )
257 | @pytest.mark.parametrize(
258 | "index, param",
259 | [(data.insert_lower, "on"), (data.insert_higher, "on")],
260 | )
261 | def test_it_sets_and_gets_patch_insert_bool_params(self, index, param, value):
262 | setattr(vm.patch.insert[index], param, value)
263 | assert getattr(vm.patch.insert[index], param) == value
264 |
265 | """ option tests """
266 |
267 | @pytest.mark.parametrize(
268 | "param",
269 | [("monitoronsel")],
270 | )
271 | def test_it_sets_and_gets_option_bool_params(self, param, value):
272 | setattr(vm.option, param, value)
273 | assert getattr(vm.option, param) == value
274 |
275 |
276 | class TestSetAndGetIntHigher:
277 | __test__ = True
278 |
279 | """strip tests, physical and virtual"""
280 |
281 | @pytest.mark.parametrize(
282 | "index,param,value",
283 | [
284 | (data.phys_in, "limit", -40),
285 | (data.phys_in, "limit", 12),
286 | (data.virt_in, "k", 0),
287 | (data.virt_in, "k", 4),
288 | ],
289 | )
290 | def test_it_sets_and_gets_strip_bool_params(self, index, param, value):
291 | setattr(vm.strip[index], param, value)
292 | assert getattr(vm.strip[index], param) == value
293 |
294 | """ vban outstream tests """
295 |
296 | @pytest.mark.parametrize(
297 | "index,param,value",
298 | [(data.vban_out, "sr", 48000)],
299 | )
300 | def test_it_sets_and_gets_vban_outstream_bool_params(self, index, param, value):
301 | setattr(vm.vban.outstream[index], param, value)
302 | assert getattr(vm.vban.outstream[index], param) == value
303 |
304 | """ patch.asio tests """
305 |
306 | @pytest.mark.skipif(
307 | data.name == "basic",
308 | reason="Skip test if kind is basic",
309 | )
310 | @pytest.mark.parametrize(
311 | "index,value",
312 | [
313 | (0, 1),
314 | (data.asio_in, 4),
315 | ],
316 | )
317 | def test_it_sets_and_gets_patch_asio_in_int_params(self, index, value):
318 | vm.patch.asio[index].set(value)
319 | assert vm.patch.asio[index].get() == value
320 |
321 | """ patch.A2[i]-A5[i] tests """
322 |
323 | @pytest.mark.skipif(
324 | data.name == "basic",
325 | reason="Skip test if kind is basic",
326 | )
327 | @pytest.mark.parametrize(
328 | "index,value",
329 | [
330 | (0, 1),
331 | (data.asio_out, 4),
332 | ],
333 | )
334 | def test_it_sets_and_gets_patch_asio_out_int_params(self, index, value):
335 | vm.patch.A2[index].set(value)
336 | assert vm.patch.A2[index].get() == value
337 | vm.patch.A5[index].set(value)
338 | assert vm.patch.A5[index].get() == value
339 |
340 | """ patch.composite tests """
341 |
342 | @pytest.mark.skipif(
343 | data.name == "basic",
344 | reason="Skip test if kind is basic",
345 | )
346 | @pytest.mark.parametrize(
347 | "index,value",
348 | [
349 | (0, 3),
350 | (0, data.channels),
351 | (7, 8),
352 | (7, data.channels),
353 | ],
354 | )
355 | def test_it_sets_and_gets_patch_composite_int_params(self, index, value):
356 | vm.patch.composite[index].set(value)
357 | assert vm.patch.composite[index].get() == value
358 |
359 | """ option tests """
360 |
361 | @pytest.mark.skipif(
362 | data.name == "basic",
363 | reason="Skip test if kind is basic",
364 | )
365 | @pytest.mark.parametrize(
366 | "index,value",
367 | [
368 | (data.phys_out, 30),
369 | (data.phys_out, 500),
370 | ],
371 | )
372 | def test_it_sets_and_gets_patch_delay_int_params(self, index, value):
373 | vm.option.delay[index].set(value)
374 | assert vm.option.delay[index].get() == value
375 |
376 | """ recorder tests """
377 |
378 | @pytest.mark.skipif(
379 | data.name == "basic",
380 | reason="Skip test if kind is basic",
381 | )
382 | @pytest.mark.parametrize(
383 | "param,value",
384 | [
385 | ("samplerate", 32000),
386 | ("samplerate", 96000),
387 | ("bitresolution", 16),
388 | ("bitresolution", 32),
389 | ],
390 | )
391 | def test_it_sets_and_gets_recorder_int_params(self, param, value):
392 | assert hasattr(vm.recorder, param)
393 | setattr(vm.recorder, param, value)
394 | assert getattr(vm.recorder, param) == value
395 |
396 |
397 | class TestSetAndGetFloatHigher:
398 | __test__ = True
399 |
400 | """strip tests, physical and virtual"""
401 |
402 | @pytest.mark.parametrize(
403 | "index,param,value",
404 | [
405 | (data.phys_in, "gain", -3.6),
406 | (data.virt_in, "gain", 5.8),
407 | ],
408 | )
409 | def test_it_sets_and_gets_strip_float_params(self, index, param, value):
410 | setattr(vm.strip[index], param, value)
411 | assert getattr(vm.strip[index], param) == value
412 |
413 | @pytest.mark.parametrize(
414 | "index,value",
415 | [(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
416 | )
417 | def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
418 | assert len(vm.strip[index].levels.prefader) == value
419 |
420 | @pytest.mark.parametrize(
421 | "index,value",
422 | [(data.phys_in, 2), (data.phys_in, 2), (data.virt_in, 8), (data.virt_in, 8)],
423 | )
424 | def test_it_gets_postmute_levels_and_compares_length_of_array(self, index, value):
425 | assert len(vm.strip[index].levels.postmute) == value
426 |
427 | @pytest.mark.skipif(
428 | data.name != "potato",
429 | reason="Only test if logged into Potato version",
430 | )
431 | @pytest.mark.parametrize(
432 | "index, j, value",
433 | [
434 | (data.phys_in, 0, -20.7),
435 | (data.virt_in, 3, -60),
436 | (data.virt_in, 4, 3.6),
437 | (data.phys_in, 4, -12.7),
438 | ],
439 | )
440 | def test_it_sets_and_gets_strip_gainlayer_values(self, index, j, value):
441 | vm.strip[index].gainlayer[j].gain = value
442 | assert vm.strip[index].gainlayer[j].gain == value
443 |
444 | """ strip tests, physical """
445 |
446 | @pytest.mark.parametrize(
447 | "index, param, value",
448 | [
449 | (data.phys_in, "pan_x", -0.6),
450 | (data.phys_in, "pan_x", 0.6),
451 | (data.phys_in, "color_y", 0.8),
452 | (data.phys_in, "fx_x", -0.6),
453 | ],
454 | )
455 | def test_it_sets_and_gets_strip_xy_params(self, index, param, value):
456 | assert hasattr(vm.strip[index], param)
457 | setattr(vm.strip[index], param, value)
458 | assert getattr(vm.strip[index], param) == value
459 |
460 | @pytest.mark.skipif(
461 | data.name != "potato",
462 | reason="Only test if logged into Potato version",
463 | )
464 | @pytest.mark.parametrize(
465 | "index, param, value",
466 | [
467 | (data.phys_in, "reverb", -1.6),
468 | (data.phys_in, "postfx1", True),
469 | ],
470 | )
471 | def test_it_sets_and_gets_strip_effects_params(self, index, param, value):
472 | assert hasattr(vm.strip[index], param)
473 | setattr(vm.strip[index], param, value)
474 | assert getattr(vm.strip[index], param) == value
475 |
476 | @pytest.mark.skipif(
477 | data.name != "potato",
478 | reason="Only test if logged into Potato version",
479 | )
480 | @pytest.mark.parametrize(
481 | "index, param, value",
482 | [
483 | (data.phys_in, "gainin", -8.6),
484 | (data.phys_in, "knee", 0.5),
485 | ],
486 | )
487 | def test_it_sets_and_gets_strip_comp_params(self, index, param, value):
488 | assert hasattr(vm.strip[index].comp, param)
489 | setattr(vm.strip[index].comp, param, value)
490 | assert getattr(vm.strip[index].comp, param) == value
491 |
492 | @pytest.mark.skipif(
493 | data.name != "potato",
494 | reason="Only test if logged into Potato version",
495 | )
496 | @pytest.mark.parametrize(
497 | "index, param, value",
498 | [
499 | (data.phys_in, "bpsidechain", 120),
500 | (data.phys_in, "hold", 3000),
501 | ],
502 | )
503 | def test_it_sets_and_gets_strip_gate_params(self, index, param, value):
504 | assert hasattr(vm.strip[index].gate, param)
505 | setattr(vm.strip[index].gate, param, value)
506 | assert getattr(vm.strip[index].gate, param) == value
507 |
508 | @pytest.mark.skipif(
509 | data.name != "potato",
510 | reason="Only test if logged into Potato version",
511 | )
512 | @pytest.mark.parametrize(
513 | "index, param, value",
514 | [
515 | (data.phys_in, "knob", -8.6),
516 | ],
517 | )
518 | def test_it_sets_and_gets_strip_denoiser_params(self, index, param, value):
519 | setattr(vm.strip[index].denoiser, param, value)
520 | assert getattr(vm.strip[index].denoiser, param) == value
521 |
522 | """ strip tests, virtual """
523 |
524 | @pytest.mark.parametrize(
525 | "index, param, value",
526 | [
527 | (data.virt_in, "pan_x", -0.6),
528 | (data.virt_in, "pan_x", 0.6),
529 | (data.virt_in, "treble", -1.6),
530 | (data.virt_in, "mid", 5.8),
531 | (data.virt_in, "bass", -8.1),
532 | ],
533 | )
534 | def test_it_sets_and_gets_strip_eq_params(self, index, param, value):
535 | setattr(vm.strip[index], param, value)
536 | assert getattr(vm.strip[index], param) == value
537 |
538 | """ bus tests, physical and virtual """
539 |
540 | @pytest.mark.skipif(
541 | data.name != "potato",
542 | reason="Only test if logged into Potato version",
543 | )
544 | @pytest.mark.parametrize(
545 | "index, param, value",
546 | [(data.phys_out, "returnreverb", 3.6), (data.virt_out, "returnfx1", 5.8)],
547 | )
548 | def test_it_sets_and_gets_bus_effects_float_params(self, index, param, value):
549 | assert hasattr(vm.bus[index], param)
550 | setattr(vm.bus[index], param, value)
551 | assert getattr(vm.bus[index], param) == value
552 |
553 | @pytest.mark.parametrize(
554 | "index, param, value",
555 | [(data.phys_out, "gain", -3.6), (data.virt_out, "gain", 5.8)],
556 | )
557 | def test_it_sets_and_gets_bus_float_params(self, index, param, value):
558 | setattr(vm.bus[index], param, value)
559 | assert getattr(vm.bus[index], param) == value
560 |
561 | @pytest.mark.parametrize(
562 | "index,value",
563 | [(data.phys_out, 8), (data.virt_out, 8)],
564 | )
565 | def test_it_gets_prefader_levels_and_compares_length_of_array(self, index, value):
566 | assert len(vm.bus[index].levels.all) == value
567 |
568 |
569 | @pytest.mark.parametrize("value", ["test0", "test1"])
570 | class TestSetAndGetStringHigher:
571 | __test__ = True
572 |
573 | """strip tests, physical and virtual"""
574 |
575 | @pytest.mark.parametrize(
576 | "index, param",
577 | [(data.phys_in, "label"), (data.virt_in, "label")],
578 | )
579 | def test_it_sets_and_gets_strip_string_params(self, index, param, value):
580 | setattr(vm.strip[index], param, value)
581 | assert getattr(vm.strip[index], param) == value
582 |
583 | """ bus tests, physical and virtual """
584 |
585 | @pytest.mark.parametrize(
586 | "index, param",
587 | [(data.phys_out, "label"), (data.virt_out, "label")],
588 | )
589 | def test_it_sets_and_gets_bus_string_params(self, index, param, value):
590 | setattr(vm.bus[index], param, value)
591 | assert getattr(vm.bus[index], param) == value
592 |
593 | """ vban instream tests """
594 |
595 | @pytest.mark.parametrize(
596 | "index, param",
597 | [(data.vban_in, "name")],
598 | )
599 | def test_it_sets_and_gets_vban_instream_string_params(self, index, param, value):
600 | setattr(vm.vban.instream[index], param, value)
601 | assert getattr(vm.vban.instream[index], param) == value
602 |
603 | """ vban outstream tests """
604 |
605 | @pytest.mark.parametrize(
606 | "index, param",
607 | [(data.vban_out, "name")],
608 | )
609 | def test_it_sets_and_gets_vban_outstream_string_params(self, index, param, value):
610 | setattr(vm.vban.outstream[index], param, value)
611 | assert getattr(vm.vban.outstream[index], param) == value
612 |
613 |
614 | @pytest.mark.parametrize("value", [False, True])
615 | class TestSetAndGetMacroButtonHigher:
616 | __test__ = True
617 |
618 | """macrobutton tests"""
619 |
620 | @pytest.mark.parametrize(
621 | "index, param",
622 | [
623 | (0, "state"),
624 | (39, "stateonly"),
625 | (69, "trigger"),
626 | (22, "stateonly"),
627 | (45, "state"),
628 | (65, "trigger"),
629 | ],
630 | )
631 | def test_it_sets_and_gets_macrobutton_params(self, index, param, value):
632 | setattr(vm.button[index], param, value)
633 | assert getattr(vm.button[index], param) == value
634 |
--------------------------------------------------------------------------------
/tests/test_lower.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests import data, vm
4 |
5 |
6 | class TestSetAndGetFloatLower:
7 | __test__ = True
8 |
9 | """VBVMR_SetParameterFloat, VBVMR_GetParameterFloat"""
10 |
11 | @pytest.mark.parametrize(
12 | "param,value",
13 | [
14 | (f"Strip[{data.phys_in}].Mute", 1),
15 | (f"Bus[{data.virt_out}].Eq.on", 1),
16 | (f"Strip[{data.phys_in}].Mute", 0),
17 | (f"Bus[{data.virt_out}].Eq.on", 0),
18 | ],
19 | )
20 | def test_it_sets_and_gets_mute_eq_float_params(self, param, value):
21 | vm.set(param, value)
22 | assert (round(vm.get(param))) == value
23 |
24 | @pytest.mark.parametrize(
25 | "param,value",
26 | [
27 | (f"Strip[{data.phys_in}].Comp", 5.3),
28 | (f"Strip[{data.virt_in}].Gain", -37.5),
29 | (f"Bus[{data.virt_out}].Gain", -22.7),
30 | ],
31 | )
32 | def test_it_sets_and_gets_comp_gain_float_params(self, param, value):
33 | vm.set(param, value)
34 | assert (round(vm.get(param), 1)) == value
35 |
36 |
37 | @pytest.mark.parametrize("value", ["test0", "test1"])
38 | class TestSetAndGetStringLower:
39 | __test__ = True
40 |
41 | """VBVMR_SetParameterStringW, VBVMR_GetParameterStringW"""
42 |
43 | @pytest.mark.parametrize(
44 | "param",
45 | [(f"Strip[{data.phys_out}].label"), (f"Bus[{data.virt_out}].label")],
46 | )
47 | def test_it_sets_and_gets_string_params(self, param, value):
48 | vm.set(param, value)
49 | assert vm.get(param, string=True) == value
50 |
51 |
52 | @pytest.mark.parametrize("value", [0, 1])
53 | class TestMacroButtonsLower:
54 | __test__ = True
55 |
56 | """VBVMR_MacroButton_SetStatus, VBVMR_MacroButton_GetStatus"""
57 |
58 | @pytest.mark.parametrize(
59 | "index, mode",
60 | [(33, 1), (49, 1)],
61 | )
62 | def test_it_sets_and_gets_macrobuttons_state(self, index, mode, value):
63 | vm.set_buttonstatus(index, value, mode)
64 | assert vm.get_buttonstatus(index, mode) == value
65 |
66 | @pytest.mark.parametrize(
67 | "index, mode",
68 | [(14, 2), (12, 2)],
69 | )
70 | def test_it_sets_and_gets_macrobuttons_stateonly(self, index, mode, value):
71 | vm.set_buttonstatus(index, value, mode)
72 | assert vm.get_buttonstatus(index, mode) == value
73 |
74 | @pytest.mark.parametrize(
75 | "index, mode",
76 | [(50, 3), (65, 3)],
77 | )
78 | def test_it_sets_and_gets_macrobuttons_trigger(self, index, mode, value):
79 | vm.set_buttonstatus(index, value, mode)
80 | assert vm.get_buttonstatus(index, mode) == value
81 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py310,py311,py312,py313
3 |
4 | [testenv]
5 | passenv = *
6 | setenv = VIRTUALENV_DISCOVERY=pyenv
7 | allowlist_externals = poetry
8 | commands_pre =
9 | poetry install --no-interaction --no-root
10 | commands =
11 | poetry run pytest tests
12 |
13 | [testenv:genbadge]
14 | passenv = *
15 | setenv = VIRTUALENV_DISCOVERY=pyenv
16 | allowlist_externals = poetry
17 | deps =
18 | genbadge[all]
19 | pytest-html
20 | commands_pre =
21 | poetry install --no-interaction --no-root
22 | commands =
23 | poetry run pytest --capture=tee-sys --junitxml=./tests/reports/junit-${KIND}.xml --html=./tests/reports/${KIND}.html tests
24 | poetry run genbadge tests -t 90 -i ./tests/reports/junit-${KIND}.xml -o ./tests/reports/badge-${KIND}.svg
25 |
26 | [testenv:dsl]
27 | setenv = VIRTUALENV_DISCOVERY=pyenv
28 | allowlist_externals = poetry
29 | deps = pyparsing
30 | commands_pre =
31 | poetry install --no-interaction --no-root --without dev
32 | commands =
33 | poetry run python examples/dsl
34 |
35 | [testenv:obs]
36 | setenv = VIRTUALENV_DISCOVERY=pyenv
37 | allowlist_externals = poetry
38 | deps = obsws-python
39 | commands_pre =
40 | poetry install --no-interaction --no-root --without dev
41 | commands =
42 | poetry run python examples/obs
43 |
--------------------------------------------------------------------------------
/voicemeeterlib/__init__.py:
--------------------------------------------------------------------------------
1 | from .factory import request_remote_obj as api
2 |
3 | __ALL__ = ['api']
4 |
--------------------------------------------------------------------------------
/voicemeeterlib/bus.py:
--------------------------------------------------------------------------------
1 | import time
2 | from abc import abstractmethod
3 | from enum import IntEnum
4 | from math import log
5 | from typing import Union
6 |
7 | from . import kinds
8 | from .iremote import IRemote
9 | from .meta import bus_mode_prop, device_prop, float_prop
10 |
11 | BusModes = IntEnum(
12 | 'BusModes',
13 | 'normal amix bmix repeat composite tvmix upmix21 upmix41 upmix61 centeronly lfeonly rearonly',
14 | start=0,
15 | )
16 |
17 |
18 | class Bus(IRemote):
19 | """
20 | Implements the common interface
21 |
22 | Defines concrete implementation for bus
23 | """
24 |
25 | @abstractmethod
26 | def __str__(self):
27 | pass
28 |
29 | @property
30 | def identifier(self) -> str:
31 | return f'bus[{self.index}]'
32 |
33 | @property
34 | def mute(self) -> bool:
35 | return self.getter('mute') == 1
36 |
37 | @mute.setter
38 | def mute(self, val: bool):
39 | self.setter('mute', 1 if val else 0)
40 |
41 | @property
42 | def mono(self) -> bool:
43 | return self.getter('mono') == 1
44 |
45 | @mono.setter
46 | def mono(self, val: bool):
47 | self.setter('mono', 1 if val else 0)
48 |
49 | @property
50 | def sel(self) -> bool:
51 | return self.getter('sel') == 1
52 |
53 | @sel.setter
54 | def sel(self, val: bool):
55 | self.setter('sel', 1 if val else 0)
56 |
57 | @property
58 | def label(self) -> str:
59 | return self.getter('Label', is_string=True)
60 |
61 | @label.setter
62 | def label(self, val: str):
63 | self.setter('Label', str(val))
64 |
65 | @property
66 | def gain(self) -> float:
67 | return round(self.getter('gain'), 1)
68 |
69 | @gain.setter
70 | def gain(self, val: float):
71 | self.setter('gain', val)
72 |
73 | @property
74 | def monitor(self) -> bool:
75 | return self.getter('monitor') == 1
76 |
77 | @monitor.setter
78 | def monitor(self, val: bool):
79 | self.setter('monitor', 1 if val else 0)
80 |
81 | def fadeto(self, target: float, time_: int):
82 | self.setter('FadeTo', f'({target}, {time_})')
83 | time.sleep(self._remote.DELAY)
84 |
85 | def fadeby(self, change: float, time_: int):
86 | self.setter('FadeBy', f'({change}, {time_})')
87 | time.sleep(self._remote.DELAY)
88 |
89 |
90 | class BusEQ(IRemote):
91 | @property
92 | def identifier(self) -> str:
93 | return f'Bus[{self.index}].eq'
94 |
95 | @property
96 | def on(self) -> bool:
97 | return self.getter('on') == 1
98 |
99 | @on.setter
100 | def on(self, val: bool):
101 | self.setter('on', 1 if val else 0)
102 |
103 | @property
104 | def ab(self) -> bool:
105 | return self.getter('ab') == 1
106 |
107 | @ab.setter
108 | def ab(self, val: bool):
109 | self.setter('ab', 1 if val else 0)
110 |
111 |
112 | class PhysicalBus(Bus):
113 | @classmethod
114 | def make(cls, remote, i, kind):
115 | """
116 | Factory method for PhysicalBus.
117 |
118 | Returns a PhysicalBus class.
119 | """
120 | kls = (cls,)
121 | if kind.name == 'potato':
122 | EFFECTS_cls = _make_effects_mixin()
123 | kls += (EFFECTS_cls,)
124 | return type(
125 | 'PhysicalBus',
126 | kls,
127 | {
128 | 'device': BusDevice.make(remote, i),
129 | },
130 | )
131 |
132 | def __str__(self):
133 | return f'{type(self).__name__}{self.index}'
134 |
135 |
136 | class BusDevice(IRemote):
137 | @classmethod
138 | def make(cls, remote, i):
139 | """
140 | Factory function for bus.device.
141 |
142 | Returns a BusDevice class of a kind.
143 | """
144 | DEVICE_cls = type(
145 | f'BusDevice{remote.kind}',
146 | (cls,),
147 | {
148 | **{
149 | param: device_prop(param)
150 | for param in [
151 | 'wdm',
152 | 'ks',
153 | 'mme',
154 | 'asio',
155 | ]
156 | },
157 | },
158 | )
159 | return DEVICE_cls(remote, i)
160 |
161 | @property
162 | def identifier(self) -> str:
163 | return f'Bus[{self.index}].device'
164 |
165 | @property
166 | def name(self) -> str:
167 | return self.getter('name', is_string=True)
168 |
169 | @property
170 | def sr(self) -> int:
171 | return int(self.getter('sr'))
172 |
173 |
174 | class VirtualBus(Bus):
175 | @classmethod
176 | def make(cls, remote, i, kind):
177 | """
178 | Factory method for VirtualBus.
179 |
180 | If basic kind subclass physical bus.
181 |
182 | Returns a VirtualBus class.
183 | """
184 | kls = (cls,)
185 | if kind.name == 'basic':
186 | return type(
187 | 'VirtualBus',
188 | kls,
189 | {
190 | 'device': BusDevice.make(remote, i),
191 | },
192 | )
193 | elif kind.name == 'potato':
194 | EFFECTS_cls = _make_effects_mixin()
195 | kls += (EFFECTS_cls,)
196 | return type('VirtualBus', kls, {})
197 |
198 | def __str__(self):
199 | return f'{type(self).__name__}{self.index}'
200 |
201 |
202 | class BusLevel(IRemote):
203 | def __init__(self, remote, index):
204 | super().__init__(remote, index)
205 | self.range = _make_bus_level_maps[remote.kind.name][self.index]
206 |
207 | def getter(self, mode):
208 | """
209 | Returns a tuple of level values for the channel.
210 |
211 | If observables thread running and level updates are subscribed to, fetch values from cache
212 |
213 | Otherwise call CAPI func.
214 | """
215 |
216 | def fget(x):
217 | return round(20 * log(x, 10), 1) if x > 0 else -200.0
218 |
219 | if not self._remote.stopped() and self._remote.event.ldirty:
220 | vals = self._remote.cache['bus_level'][self.range[0] : self.range[-1]]
221 | else:
222 | vals = [self._remote.get_level(mode, i) for i in range(*self.range)]
223 |
224 | return tuple(fget(val) for val in vals)
225 |
226 | @property
227 | def identifier(self) -> str:
228 | return f'Bus[{self.index}]'
229 |
230 | @property
231 | def all(self) -> tuple:
232 | return self.getter(3)
233 |
234 | @property
235 | def isdirty(self) -> bool:
236 | """
237 | Returns dirty status for this specific channel.
238 |
239 | Expected to be used in a callback only.
240 | """
241 | if not self._remote.stopped():
242 | return any(self._remote._bus_comp[self.range[0] : self.range[-1]])
243 |
244 | is_updated = isdirty
245 |
246 |
247 | def make_bus_level_map(kind):
248 | return tuple((i, i + 8) for i in range(0, (kind.phys_out + kind.virt_out) * 8, 8))
249 |
250 |
251 | _make_bus_level_maps = {kind.name: make_bus_level_map(kind) for kind in kinds.all}
252 |
253 |
254 | def _make_bus_mode_mixin():
255 | """Creates a mixin of Bus Modes."""
256 |
257 | def identifier(self) -> str:
258 | return f'Bus[{self.index}].mode'
259 |
260 | def get(self) -> str:
261 | time.sleep(0.01)
262 | for i, val in enumerate(
263 | [
264 | self.amix,
265 | self.bmix,
266 | self.repeat,
267 | self.composite,
268 | self.tvmix,
269 | self.upmix21,
270 | self.upmix41,
271 | self.upmix61,
272 | self.centeronly,
273 | self.lfeonly,
274 | self.rearonly,
275 | ]
276 | ):
277 | if val:
278 | return BusModes(i + 1).name
279 | return 'normal'
280 |
281 | return type(
282 | 'BusModeMixin',
283 | (IRemote,),
284 | {
285 | 'identifier': property(identifier),
286 | **{mode.name: bus_mode_prop(mode.name) for mode in BusModes},
287 | 'get': get,
288 | },
289 | )
290 |
291 |
292 | def _make_effects_mixin():
293 | """creates an fx mixin"""
294 | return type(
295 | 'FX',
296 | (),
297 | {
298 | **{
299 | f'return{param}': float_prop(f'return{param}')
300 | for param in ['reverb', 'delay', 'fx1', 'fx2']
301 | },
302 | },
303 | )
304 |
305 |
306 | def bus_factory(is_phys_bus, remote, i) -> Union[PhysicalBus, VirtualBus]:
307 | """
308 | Factory method for buses
309 |
310 | Returns a physical or virtual bus subclass
311 | """
312 | BUS_cls = (
313 | PhysicalBus.make(remote, i, remote.kind)
314 | if is_phys_bus
315 | else VirtualBus.make(remote, i, remote.kind)
316 | )
317 | BUSMODEMIXIN_cls = _make_bus_mode_mixin()
318 | return type(
319 | f'{BUS_cls.__name__}{remote.kind}',
320 | (BUS_cls,),
321 | {
322 | 'levels': BusLevel(remote, i),
323 | 'mode': BUSMODEMIXIN_cls(remote, i),
324 | 'eq': BusEQ(remote, i),
325 | },
326 | )(remote, i)
327 |
328 |
329 | def request_bus_obj(phys_bus, remote, i) -> Bus:
330 | """
331 | Bus entry point. Wraps factory method.
332 |
333 | Returns a reference to a bus subclass of a kind
334 | """
335 | return bus_factory(phys_bus, remote, i)
336 |
--------------------------------------------------------------------------------
/voicemeeterlib/cbindings.py:
--------------------------------------------------------------------------------
1 | import ctypes as ct
2 | import logging
3 | from abc import ABCMeta
4 | from ctypes.wintypes import CHAR, FLOAT, LONG, WCHAR
5 |
6 | from .error import CAPIError
7 | from .inst import libc
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | class CBindings(metaclass=ABCMeta):
13 | """
14 | C bindings defined here.
15 |
16 | Maps expected ctype argument and res types for each binding.
17 | """
18 |
19 | logger_cbindings = logger.getChild('CBindings')
20 |
21 | bind_login = libc.VBVMR_Login
22 | bind_login.restype = LONG
23 | bind_login.argtypes = None
24 |
25 | bind_logout = libc.VBVMR_Logout
26 | bind_logout.restype = LONG
27 | bind_logout.argtypes = None
28 |
29 | bind_run_voicemeeter = libc.VBVMR_RunVoicemeeter
30 | bind_run_voicemeeter.restype = LONG
31 | bind_run_voicemeeter.argtypes = [LONG]
32 |
33 | bind_get_voicemeeter_type = libc.VBVMR_GetVoicemeeterType
34 | bind_get_voicemeeter_type.restype = LONG
35 | bind_get_voicemeeter_type.argtypes = [ct.POINTER(LONG)]
36 |
37 | bind_get_voicemeeter_version = libc.VBVMR_GetVoicemeeterVersion
38 | bind_get_voicemeeter_version.restype = LONG
39 | bind_get_voicemeeter_version.argtypes = [ct.POINTER(LONG)]
40 |
41 | if hasattr(libc, 'VBVMR_MacroButton_IsDirty'):
42 | bind_macro_button_is_dirty = libc.VBVMR_MacroButton_IsDirty
43 | bind_macro_button_is_dirty.restype = LONG
44 | bind_macro_button_is_dirty.argtypes = None
45 |
46 | if hasattr(libc, 'VBVMR_MacroButton_GetStatus'):
47 | bind_macro_button_get_status = libc.VBVMR_MacroButton_GetStatus
48 | bind_macro_button_get_status.restype = LONG
49 | bind_macro_button_get_status.argtypes = [LONG, ct.POINTER(FLOAT), LONG]
50 |
51 | if hasattr(libc, 'VBVMR_MacroButton_SetStatus'):
52 | bind_macro_button_set_status = libc.VBVMR_MacroButton_SetStatus
53 | bind_macro_button_set_status.restype = LONG
54 | bind_macro_button_set_status.argtypes = [LONG, FLOAT, LONG]
55 |
56 | bind_is_parameters_dirty = libc.VBVMR_IsParametersDirty
57 | bind_is_parameters_dirty.restype = LONG
58 | bind_is_parameters_dirty.argtypes = None
59 |
60 | bind_get_parameter_float = libc.VBVMR_GetParameterFloat
61 | bind_get_parameter_float.restype = LONG
62 | bind_get_parameter_float.argtypes = [ct.POINTER(CHAR), ct.POINTER(FLOAT)]
63 |
64 | bind_set_parameter_float = libc.VBVMR_SetParameterFloat
65 | bind_set_parameter_float.restype = LONG
66 | bind_set_parameter_float.argtypes = [ct.POINTER(CHAR), FLOAT]
67 |
68 | bind_get_parameter_string_w = libc.VBVMR_GetParameterStringW
69 | bind_get_parameter_string_w.restype = LONG
70 | bind_get_parameter_string_w.argtypes = [ct.POINTER(CHAR), ct.POINTER(WCHAR * 512)]
71 |
72 | bind_set_parameter_string_w = libc.VBVMR_SetParameterStringW
73 | bind_set_parameter_string_w.restype = LONG
74 | bind_set_parameter_string_w.argtypes = [ct.POINTER(CHAR), ct.POINTER(WCHAR)]
75 |
76 | bind_set_parameters = libc.VBVMR_SetParameters
77 | bind_set_parameters.restype = LONG
78 | bind_set_parameters.argtypes = [ct.POINTER(CHAR)]
79 |
80 | bind_get_level = libc.VBVMR_GetLevel
81 | bind_get_level.restype = LONG
82 | bind_get_level.argtypes = [LONG, LONG, ct.POINTER(FLOAT)]
83 |
84 | bind_input_get_device_number = libc.VBVMR_Input_GetDeviceNumber
85 | bind_input_get_device_number.restype = LONG
86 | bind_input_get_device_number.argtypes = None
87 |
88 | bind_input_get_device_desc_w = libc.VBVMR_Input_GetDeviceDescW
89 | bind_input_get_device_desc_w.restype = LONG
90 | bind_input_get_device_desc_w.argtypes = [
91 | LONG,
92 | ct.POINTER(LONG),
93 | ct.POINTER(WCHAR * 256),
94 | ct.POINTER(WCHAR * 256),
95 | ]
96 |
97 | bind_output_get_device_number = libc.VBVMR_Output_GetDeviceNumber
98 | bind_output_get_device_number.restype = LONG
99 | bind_output_get_device_number.argtypes = None
100 |
101 | bind_output_get_device_desc_w = libc.VBVMR_Output_GetDeviceDescW
102 | bind_output_get_device_desc_w.restype = LONG
103 | bind_output_get_device_desc_w.argtypes = [
104 | LONG,
105 | ct.POINTER(LONG),
106 | ct.POINTER(WCHAR * 256),
107 | ct.POINTER(WCHAR * 256),
108 | ]
109 |
110 | bind_get_midi_message = libc.VBVMR_GetMidiMessage
111 | bind_get_midi_message.restype = LONG
112 | bind_get_midi_message.argtypes = [ct.POINTER(CHAR * 1024), LONG]
113 |
114 | def call(self, func, *args, ok=(0,), ok_exp=None):
115 | try:
116 | res = func(*args)
117 | if ok_exp is None:
118 | if res not in ok:
119 | raise CAPIError(func.__name__, res)
120 | elif not ok_exp(res) and res not in ok:
121 | raise CAPIError(func.__name__, res)
122 | return res
123 | except CAPIError as e:
124 | self.logger_cbindings.exception(f'{type(e).__name__}: {e}')
125 | raise
126 |
--------------------------------------------------------------------------------
/voicemeeterlib/command.py:
--------------------------------------------------------------------------------
1 | from .iremote import IRemote
2 | from .meta import action_fn
3 |
4 |
5 | class Command(IRemote):
6 | """
7 | Implements the common interface
8 |
9 | Defines concrete implementation for command
10 | """
11 |
12 | @classmethod
13 | def make(cls, remote):
14 | """
15 | Factory function for command class.
16 |
17 | Returns a Command class of a kind.
18 | """
19 | CMD_cls = type(
20 | f'Command{remote.kind}',
21 | (cls,),
22 | {
23 | **{
24 | param: action_fn(param) for param in ['show', 'shutdown', 'restart']
25 | },
26 | 'hide': action_fn('show', val=0),
27 | },
28 | )
29 | return CMD_cls(remote)
30 |
31 | def __str__(self):
32 | return f'{type(self).__name__}'
33 |
34 | @property
35 | def identifier(self) -> str:
36 | return 'Command'
37 |
38 | def set_showvbanchat(self, val: bool):
39 | self.setter('DialogShow.VBANCHAT', 1 if val else 0)
40 |
41 | showvbanchat = property(fset=set_showvbanchat)
42 |
43 | def set_lock(self, val: bool):
44 | self.setter('lock', 1 if val else 0)
45 |
46 | lock = property(fset=set_lock)
47 |
48 | def reset(self):
49 | self._remote.apply_config('reset')
50 |
--------------------------------------------------------------------------------
/voicemeeterlib/config.py:
--------------------------------------------------------------------------------
1 | import itertools
2 | import logging
3 | from pathlib import Path
4 |
5 | from .error import VMError
6 |
7 | try:
8 | import tomllib
9 | except ModuleNotFoundError:
10 | import tomli as tomllib
11 |
12 | from .kinds import request_kind_map as kindmap
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | class TOMLStrBuilder:
18 | """builds a config profile, as a string, for the toml parser"""
19 |
20 | def __init__(self, kind):
21 | self.kind = kind
22 | self.higher = itertools.chain(
23 | [f'strip-{i}' for i in range(kind.num_strip)],
24 | [f'bus-{i}' for i in range(kind.num_bus)],
25 | )
26 |
27 | def init_config(self, profile=None):
28 | self.virt_strip_params = (
29 | [
30 | 'mute = false',
31 | 'mono = false',
32 | 'solo = false',
33 | 'gain = 0.0',
34 | ]
35 | + [f'A{i} = false' for i in range(1, self.kind.phys_out + 1)]
36 | + [f'B{i} = false' for i in range(1, self.kind.virt_out + 1)]
37 | )
38 | self.phys_strip_params = self.virt_strip_params + [
39 | 'comp.knob = 0.0',
40 | 'gate.knob = 0.0',
41 | 'denoiser.knob = 0.0',
42 | 'eq.on = false',
43 | ]
44 | self.bus_params = [
45 | 'mono = false',
46 | 'eq.on = false',
47 | 'mute = false',
48 | 'gain = 0.0',
49 | ]
50 |
51 | if profile == 'reset':
52 | self.reset_config()
53 |
54 | def reset_config(self):
55 | self.phys_strip_params = list(
56 | map(lambda x: x.replace('B1 = false', 'B1 = true'), self.phys_strip_params)
57 | )
58 | self.virt_strip_params = list(
59 | map(lambda x: x.replace('A1 = false', 'A1 = true'), self.virt_strip_params)
60 | )
61 |
62 | def build(self, profile='reset'):
63 | self.init_config(profile)
64 | toml_str = str()
65 | for eachclass in self.higher:
66 | toml_str += f'[{eachclass}]\n'
67 | toml_str = self.join(eachclass, toml_str)
68 | return toml_str
69 |
70 | def join(self, eachclass, toml_str):
71 | kls, index = eachclass.split('-')
72 | match kls:
73 | case 'strip':
74 | toml_str += ('\n').join(
75 | self.phys_strip_params
76 | if int(index) < self.kind.phys_in
77 | else self.virt_strip_params
78 | )
79 | case 'bus':
80 | toml_str += ('\n').join(self.bus_params)
81 | case _:
82 | pass
83 | return toml_str + '\n'
84 |
85 |
86 | class TOMLDataExtractor:
87 | def __init__(self, file):
88 | with open(file, 'rb') as f:
89 | self._data = tomllib.load(f)
90 |
91 | @property
92 | def data(self):
93 | return self._data
94 |
95 | @data.setter
96 | def data(self, value):
97 | self._data = value
98 |
99 |
100 | def dataextraction_factory(file):
101 | """
102 | factory function for parser
103 |
104 | this opens the possibility for other parsers to be added
105 | """
106 | if file.suffix == '.toml':
107 | extractor = TOMLDataExtractor
108 | else:
109 | raise ValueError('Cannot extract data from {}'.format(file))
110 | return extractor(file)
111 |
112 |
113 | class SingletonType(type):
114 | """ensure only a single instance of Loader object"""
115 |
116 | _instances = {}
117 |
118 | def __call__(cls, *args, **kwargs):
119 | if cls not in cls._instances:
120 | cls._instances[cls] = super(SingletonType, cls).__call__(*args, **kwargs)
121 | return cls._instances[cls]
122 |
123 |
124 | class Loader(metaclass=SingletonType):
125 | """
126 | invokes the parser
127 |
128 | checks if config already in memory
129 |
130 | loads data into memory if not found
131 | """
132 |
133 | def __init__(self, kind):
134 | self._kind = kind
135 | self.logger = logger.getChild(self.__class__.__name__)
136 | self._configs = dict()
137 | self.defaults(kind)
138 | self.parser = None
139 |
140 | def defaults(self, kind):
141 | self.builder = TOMLStrBuilder(kind)
142 | toml_str = self.builder.build()
143 | self.register('reset', tomllib.loads(toml_str))
144 |
145 | def parse(self, identifier, data):
146 | if identifier in self._configs:
147 | self.logger.info(
148 | f'config file with name {identifier} already in memory, skipping..'
149 | )
150 | return
151 | try:
152 | self.parser = dataextraction_factory(data)
153 | except tomllib.TOMLDecodeError as e:
154 | ERR_MSG = (str(e), f'When attempting to load {identifier}.toml')
155 | self.logger.error(f"{type(e).__name__}: {' '.join(ERR_MSG)}")
156 | return
157 | return True
158 |
159 | def register(self, identifier, data=None):
160 | self._configs[identifier] = data if data else self.parser.data
161 | self.logger.info(f'config {self.name}/{identifier} loaded into memory')
162 |
163 | def deregister(self):
164 | self._configs.clear()
165 | self.defaults(self._kind)
166 |
167 | @property
168 | def configs(self):
169 | return self._configs
170 |
171 | @property
172 | def name(self):
173 | return self._kind.name
174 |
175 |
176 | def loader(kind):
177 | """
178 | traverses defined paths for config files
179 |
180 | directs the loader
181 |
182 | returns configs loaded into memory
183 | """
184 | logger_loader = logger.getChild('loader')
185 | loader = Loader(kind)
186 |
187 | for path in (
188 | Path.cwd() / 'configs' / kind.name,
189 | Path.home() / '.config' / 'voicemeeter' / kind.name,
190 | Path.home() / 'Documents' / 'Voicemeeter' / 'configs' / kind.name,
191 | ):
192 | if path.is_dir():
193 | logger_loader.info(f'Checking [{path}] for TOML config files:')
194 | for file in path.glob('*.toml'):
195 | identifier = file.with_suffix('').stem
196 | if loader.parse(identifier, file):
197 | loader.register(identifier)
198 | return loader.configs
199 |
200 |
201 | def request_config(kind_id: str):
202 | """
203 | config entry point.
204 |
205 | Returns all configs loaded into memory for a kind
206 | """
207 | try:
208 | configs = loader(kindmap(kind_id))
209 | except KeyError as e:
210 | raise VMError(f'Unknown Voicemeeter kind {kind_id}') from e
211 | return configs
212 |
--------------------------------------------------------------------------------
/voicemeeterlib/device.py:
--------------------------------------------------------------------------------
1 | from abc import abstractmethod
2 | from typing import Union
3 |
4 | from .iremote import IRemote
5 |
6 |
7 | class Adapter(IRemote):
8 | """Adapter to the common interface."""
9 |
10 | @abstractmethod
11 | def ins(self):
12 | pass
13 |
14 | @abstractmethod
15 | def outs(self):
16 | pass
17 |
18 | @abstractmethod
19 | def input(self):
20 | pass
21 |
22 | @abstractmethod
23 | def output(self):
24 | pass
25 |
26 | def identifier(self):
27 | pass
28 |
29 | def getter(self, index: int = None, direction: str = None) -> Union[int, dict]:
30 | if index is None:
31 | return self._remote.get_num_devices(direction)
32 |
33 | vals = self._remote.get_device_description(index, direction)
34 | types = {1: 'mme', 3: 'wdm', 4: 'ks', 5: 'asio'}
35 | return {'name': vals[0], 'type': types[vals[1]], 'id': vals[2]}
36 |
37 |
38 | class Device(Adapter):
39 | """Defines concrete implementation for device"""
40 |
41 | @classmethod
42 | def make(cls, remote):
43 | """
44 | Factory function for device.
45 |
46 | Returns a Device class of a kind.
47 | """
48 |
49 | def num_ins(cls) -> int:
50 | return cls.getter(direction='in')
51 |
52 | def num_outs(cls) -> int:
53 | return cls.getter(direction='out')
54 |
55 | DEVICE_cls = type(
56 | f'Device{remote.kind}',
57 | (cls,),
58 | {
59 | 'ins': property(num_ins),
60 | 'outs': property(num_outs),
61 | },
62 | )
63 | return DEVICE_cls(remote)
64 |
65 | def __str__(self):
66 | return f'{type(self).__name__}'
67 |
68 | def input(self, index: int) -> dict:
69 | return self.getter(index=index, direction='in')
70 |
71 | def output(self, index: int) -> dict:
72 | return self.getter(index=index, direction='out')
73 |
--------------------------------------------------------------------------------
/voicemeeterlib/error.py:
--------------------------------------------------------------------------------
1 | class VMError(Exception):
2 | """Base voicemeeterlib exception class."""
3 |
4 |
5 | class InstallError(VMError):
6 | """Exception raised when installation errors occur"""
7 |
8 |
9 | class CAPIError(VMError):
10 | """Exception raised when the C-API returns an error code"""
11 |
12 | def __init__(self, fn_name, code):
13 | self.fn_name = fn_name
14 | self.code = code
15 | if self.code == -9:
16 | message = ' '.join(
17 | (
18 | f'no bind for {self.fn_name}.',
19 | 'are you using an old version of the API?',
20 | )
21 | )
22 | else:
23 | message = f'{self.fn_name} returned {self.code}'
24 | super().__init__(message)
25 |
--------------------------------------------------------------------------------
/voicemeeterlib/event.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Iterable, Union
3 |
4 | logger = logging.getLogger(__name__)
5 |
6 |
7 | class Event:
8 | """Keeps track of event subscriptions"""
9 |
10 | def __init__(self, subs: dict):
11 | self.subs = subs
12 | self.logger = logger.getChild(self.__class__.__name__)
13 |
14 | def info(self, msg=None):
15 | info = (f'{msg} events',) if msg else ()
16 | if self.any():
17 | info += (f"now listening for {', '.join(self.get())} events",)
18 | else:
19 | info += ('not listening for any events',)
20 | self.logger.info(', '.join(info))
21 |
22 | @property
23 | def pdirty(self) -> bool:
24 | return self.subs['pdirty']
25 |
26 | @pdirty.setter
27 | def pdirty(self, val: bool):
28 | self.subs['pdirty'] = val
29 | self.info(f"pdirty {'added to' if val else 'removed from'}")
30 |
31 | @property
32 | def mdirty(self) -> bool:
33 | return self.subs['mdirty']
34 |
35 | @mdirty.setter
36 | def mdirty(self, val: bool):
37 | self.subs['mdirty'] = val
38 | self.info(f"mdirty {'added to' if val else 'removed from'}")
39 |
40 | @property
41 | def midi(self) -> bool:
42 | return self.subs['midi']
43 |
44 | @midi.setter
45 | def midi(self, val: bool):
46 | self.subs['midi'] = val
47 | self.info(f"midi {'added to' if val else 'removed from'}")
48 |
49 | @property
50 | def ldirty(self) -> bool:
51 | return self.subs['ldirty']
52 |
53 | @ldirty.setter
54 | def ldirty(self, val: bool):
55 | self.subs['ldirty'] = val
56 | self.info(f"ldirty {'added to' if val else 'removed from'}")
57 |
58 | def get(self) -> list:
59 | return [k for k, v in self.subs.items() if v]
60 |
61 | def any(self) -> bool:
62 | return any(self.subs.values())
63 |
64 | def add(self, events: Union[str, Iterable[str]]):
65 | if isinstance(events, str):
66 | events = [events]
67 | for event in events:
68 | setattr(self, event, True)
69 |
70 | def remove(self, events: Union[str, Iterable[str]]):
71 | if isinstance(events, str):
72 | events = [events]
73 | for event in events:
74 | setattr(self, event, False)
75 |
--------------------------------------------------------------------------------
/voicemeeterlib/factory.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from abc import abstractmethod
3 | from enum import IntEnum
4 | from functools import cached_property
5 | from typing import Iterable
6 |
7 | from . import misc
8 | from .bus import request_bus_obj as bus
9 | from .command import Command
10 | from .config import request_config as configs
11 | from .device import Device
12 | from .error import VMError
13 | from .kinds import KindMapClass
14 | from .kinds import request_kind_map as kindmap
15 | from .macrobutton import MacroButton
16 | from .recorder import Recorder
17 | from .remote import Remote
18 | from .strip import request_strip_obj as strip
19 | from .vban import request_vban_obj as vban
20 |
21 | logger = logging.getLogger(__name__)
22 |
23 |
24 | class FactoryBuilder:
25 | """
26 | Builder class for factories.
27 |
28 | Separates construction from representation.
29 | """
30 |
31 | BuilderProgress = IntEnum(
32 | 'BuilderProgress',
33 | 'strip bus command macrobutton vban device option recorder patch fx',
34 | start=0,
35 | )
36 |
37 | def __init__(self, factory, kind: KindMapClass):
38 | self._factory = factory
39 | self.kind = kind
40 | self._info = (
41 | f'Finished building strips for {self._factory}',
42 | f'Finished building buses for {self._factory}',
43 | f'Finished building commands for {self._factory}',
44 | f'Finished building macrobuttons for {self._factory}',
45 | f'Finished building vban in/out streams for {self._factory}',
46 | f'Finished building device for {self._factory}',
47 | f'Finished building option for {self._factory}',
48 | f'Finished building recorder for {self._factory}',
49 | f'Finished building patch for {self._factory}',
50 | f'Finished building fx for {self._factory}',
51 | )
52 | self.logger = logger.getChild(self.__class__.__name__)
53 |
54 | def _pinfo(self, name: str) -> None:
55 | """prints progress status for each step"""
56 | name = name.split('_')[1]
57 | self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
58 |
59 | def make_strip(self):
60 | self._factory.strip = tuple(
61 | strip(i < self.kind.phys_in, self._factory, i)
62 | for i in range(self.kind.num_strip)
63 | )
64 | return self
65 |
66 | def make_bus(self):
67 | self._factory.bus = tuple(
68 | bus(i < self.kind.phys_out, self._factory, i)
69 | for i in range(self.kind.num_bus)
70 | )
71 | return self
72 |
73 | def make_command(self):
74 | self._factory.command = Command.make(self._factory)
75 | return self
76 |
77 | def make_macrobutton(self):
78 | self._factory.button = tuple(MacroButton(self._factory, i) for i in range(80))
79 | return self
80 |
81 | def make_vban(self):
82 | self._factory.vban = vban(self._factory)
83 | return self
84 |
85 | def make_device(self):
86 | self._factory.device = Device.make(self._factory)
87 | return self
88 |
89 | def make_option(self):
90 | self._factory.option = misc.Option.make(self._factory)
91 | return self
92 |
93 | def make_recorder(self):
94 | self._factory.recorder = Recorder.make(self._factory)
95 | return self
96 |
97 | def make_patch(self):
98 | self._factory.patch = misc.Patch.make(self._factory)
99 | return self
100 |
101 | def make_fx(self):
102 | self._factory.fx = misc.FX(self._factory)
103 | return self
104 |
105 |
106 | class FactoryBase(Remote):
107 | """Base class for factories, subclasses Remote."""
108 |
109 | def __init__(self, kind_id: str, **kwargs):
110 | defaultkwargs = {
111 | 'sync': False,
112 | 'ratelimit': 0.033,
113 | 'pdirty': False,
114 | 'mdirty': False,
115 | 'midi': False,
116 | 'ldirty': False,
117 | 'timeout': 2,
118 | 'bits': 64,
119 | }
120 | if 'subs' in kwargs:
121 | defaultkwargs |= kwargs.pop('subs') # for backwards compatibility
122 | kwargs = defaultkwargs | kwargs
123 | self.kind = kindmap(kind_id)
124 | super().__init__(**kwargs)
125 | self.builder = FactoryBuilder(self, self.kind)
126 | self._steps = (
127 | self.builder.make_strip,
128 | self.builder.make_bus,
129 | self.builder.make_command,
130 | self.builder.make_macrobutton,
131 | self.builder.make_vban,
132 | self.builder.make_device,
133 | self.builder.make_option,
134 | )
135 | self._configs = None
136 |
137 | def __str__(self) -> str:
138 | return f'Voicemeeter {self.kind}'
139 |
140 | @property
141 | @abstractmethod
142 | def steps(self):
143 | pass
144 |
145 | @cached_property
146 | def configs(self):
147 | self._configs = configs(self.kind.name)
148 | return self._configs
149 |
150 |
151 | class BasicFactory(FactoryBase):
152 | """
153 | Represents a Basic Remote subclass
154 |
155 | Responsible for directing the builder class
156 | """
157 |
158 | def __new__(cls, *args, **kwargs):
159 | if cls is BasicFactory:
160 | raise TypeError(f"'{cls.__name__}' does not support direct instantiation")
161 | return object.__new__(cls)
162 |
163 | def __init__(self, kind_id, **kwargs):
164 | super().__init__(kind_id, **kwargs)
165 | [step()._pinfo(step.__name__) for step in self.steps]
166 |
167 | @property
168 | def steps(self) -> Iterable:
169 | """steps required to build the interface for a kind"""
170 | return self._steps
171 |
172 |
173 | class BananaFactory(FactoryBase):
174 | """
175 | Represents a Banana Remote subclass
176 |
177 | Responsible for directing the builder class
178 | """
179 |
180 | def __new__(cls, *args, **kwargs):
181 | if cls is BananaFactory:
182 | raise TypeError(f"'{cls.__name__}' does not support direct instantiation")
183 | return object.__new__(cls)
184 |
185 | def __init__(self, kind_id, **kwargs):
186 | super().__init__(kind_id, **kwargs)
187 | [step()._pinfo(step.__name__) for step in self.steps]
188 |
189 | @property
190 | def steps(self) -> Iterable:
191 | """steps required to build the interface for a kind"""
192 | return self._steps + (self.builder.make_recorder, self.builder.make_patch)
193 |
194 |
195 | class PotatoFactory(FactoryBase):
196 | """
197 | Represents a Potato Remote subclass
198 |
199 | Responsible for directing the builder class
200 | """
201 |
202 | def __new__(cls, *args, **kwargs):
203 | if cls is PotatoFactory:
204 | raise TypeError(f"'{cls.__name__}' does not support direct instantiation")
205 | return object.__new__(cls)
206 |
207 | def __init__(self, kind_id: str, **kwargs):
208 | super().__init__(kind_id, **kwargs)
209 | [step()._pinfo(step.__name__) for step in self.steps]
210 |
211 | @property
212 | def steps(self) -> Iterable:
213 | """steps required to build the interface for a kind"""
214 | return self._steps + (
215 | self.builder.make_recorder,
216 | self.builder.make_patch,
217 | self.builder.make_fx,
218 | )
219 |
220 |
221 | def remote_factory(kind_id: str, **kwargs) -> Remote:
222 | """
223 | Factory method, invokes a factory creation class of a kind
224 |
225 | Returns a Remote class of a kind
226 | """
227 | match kind_id:
228 | case 'basic':
229 | _factory = BasicFactory
230 | case 'banana':
231 | _factory = BananaFactory
232 | case 'potato':
233 | _factory = PotatoFactory
234 | case _:
235 | raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'")
236 | return type(f'Remote{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs)
237 |
238 |
239 | def request_remote_obj(kind_id: str, **kwargs) -> Remote:
240 | """
241 | Interface entry point. Wraps factory method and handles errors
242 |
243 | Returns a reference to a Remote class of a kind
244 | """
245 |
246 | logger_entry = logger.getChild('request_remote_obj')
247 |
248 | REMOTE_obj = None
249 | try:
250 | REMOTE_obj = remote_factory(kind_id, **kwargs)
251 | except (ValueError, TypeError) as e:
252 | logger_entry.exception(f'{type(e).__name__}: {e}')
253 | raise VMError(str(e)) from e
254 | return REMOTE_obj
255 |
--------------------------------------------------------------------------------
/voicemeeterlib/inst.py:
--------------------------------------------------------------------------------
1 | import ctypes as ct
2 | import platform
3 | import winreg
4 | from pathlib import Path
5 |
6 | from .error import InstallError
7 |
8 | BITS = 64 if ct.sizeof(ct.c_void_p) == 8 else 32
9 |
10 | if platform.system() != 'Windows':
11 | raise InstallError('Only Windows OS supported')
12 |
13 |
14 | VM_KEY = 'VB:Voicemeeter {17359A74-1236-5467}'
15 | REG_KEY = '\\'.join(
16 | filter(
17 | None,
18 | (
19 | 'SOFTWARE',
20 | 'WOW6432Node' if BITS == 64 else '',
21 | 'Microsoft',
22 | 'Windows',
23 | 'CurrentVersion',
24 | 'Uninstall',
25 | ),
26 | )
27 | )
28 |
29 |
30 | def get_vmpath():
31 | with winreg.OpenKey(
32 | winreg.HKEY_LOCAL_MACHINE, r'{}'.format('\\'.join((REG_KEY, VM_KEY)))
33 | ) as vm_key:
34 | return winreg.QueryValueEx(vm_key, r'UninstallString')[0].strip('"')
35 |
36 |
37 | try:
38 | vm_parent = Path(get_vmpath()).parent
39 | except FileNotFoundError as e:
40 | raise InstallError('Unable to fetch DLL path from the registry') from e
41 |
42 | DLL_NAME = f'VoicemeeterRemote{"64" if BITS == 64 else ""}.dll'
43 |
44 | dll_path = vm_parent.joinpath(DLL_NAME)
45 | if not dll_path.is_file():
46 | raise InstallError(f'Could not find {dll_path}')
47 |
48 | libc = ct.CDLL(str(dll_path))
49 |
--------------------------------------------------------------------------------
/voicemeeterlib/iremote.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import time
3 | from abc import ABCMeta, abstractmethod
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 |
8 | class IRemote(metaclass=ABCMeta):
9 | """
10 | Common interface between base class and extended (higher) classes
11 |
12 | Provides some default implementation
13 | """
14 |
15 | def __init__(self, remote, index=None):
16 | self._remote = remote
17 | self.index = index
18 | self.logger = logger.getChild(self.__class__.__name__)
19 |
20 | def getter(self, param, **kwargs):
21 | """Gets a parameter value"""
22 | self.logger.debug(f'getter: {self._cmd(param)}')
23 | return self._remote.get(self._cmd(param), **kwargs)
24 |
25 | def setter(self, param, val):
26 | """Sets a parameter value"""
27 | self.logger.debug(f'setter: {self._cmd(param)}={val}')
28 | self._remote.set(self._cmd(param), val)
29 |
30 | def _cmd(self, param):
31 | cmd = (self.identifier,)
32 | if param:
33 | cmd += (f'.{param}',)
34 | return ''.join(cmd)
35 |
36 | @abstractmethod
37 | def identifier(self):
38 | pass
39 |
40 | def apply(self, data: dict):
41 | def fget(attr, val):
42 | if attr == 'mode':
43 | return (getattr(self, attr), val, 1)
44 | return (self, attr, val)
45 |
46 | for attr, val in data.items():
47 | if not isinstance(val, dict):
48 | if attr in dir(self): # avoid calling getattr (with hasattr)
49 | target, attr, val = fget(attr, val)
50 | setattr(target, attr, val)
51 | else:
52 | self.logger.error(f'invalid attribute {attr} for {self}')
53 | else:
54 | target = getattr(self, attr)
55 | target.apply(val)
56 | return self
57 |
58 | def then_wait(self):
59 | time.sleep(self._remote.DELAY)
60 |
--------------------------------------------------------------------------------
/voicemeeterlib/kinds.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from enum import Enum, unique
3 |
4 | from .error import VMError
5 |
6 |
7 | @unique
8 | class KindId(Enum):
9 | BASIC = 1
10 | BANANA = 2
11 | POTATO = 3
12 |
13 |
14 | class SingletonType(type):
15 | """ensure only a single instance of a kind map object"""
16 |
17 | _instances = {}
18 |
19 | def __call__(cls, *args, **kwargs):
20 | if cls not in cls._instances:
21 | cls._instances[cls] = super(SingletonType, cls).__call__(*args, **kwargs)
22 | return cls._instances[cls]
23 |
24 |
25 | @dataclass(frozen=True)
26 | class KindMapClass(metaclass=SingletonType):
27 | name: str
28 | ins: tuple
29 | outs: tuple
30 | vban: tuple
31 | asio: tuple
32 | insert: int
33 | composite: int
34 |
35 | @property
36 | def phys_in(self) -> int:
37 | return self.ins[0]
38 |
39 | @property
40 | def virt_in(self) -> int:
41 | return self.ins[-1]
42 |
43 | @property
44 | def phys_out(self) -> int:
45 | return self.outs[0]
46 |
47 | @property
48 | def virt_out(self) -> int:
49 | return self.outs[-1]
50 |
51 | @property
52 | def num_strip(self) -> int:
53 | return sum(self.ins)
54 |
55 | @property
56 | def num_bus(self) -> int:
57 | return sum(self.outs)
58 |
59 | @property
60 | def num_strip_levels(self) -> int:
61 | return 2 * self.phys_in + 8 * self.virt_in
62 |
63 | @property
64 | def num_bus_levels(self) -> int:
65 | return 8 * (self.phys_out + self.virt_out)
66 |
67 | def __str__(self) -> str:
68 | return self.name.capitalize()
69 |
70 |
71 | @dataclass(frozen=True)
72 | class BasicMap(KindMapClass):
73 | ins: tuple = (2, 1)
74 | outs: tuple = (1, 1)
75 | vban: tuple = (4, 4, 1, 1)
76 | asio: tuple = (0, 0)
77 | insert: int = 0
78 | composite: int = 0
79 |
80 |
81 | @dataclass(frozen=True)
82 | class BananaMap(KindMapClass):
83 | ins: tuple = (3, 2)
84 | outs: tuple = (3, 2)
85 | vban: tuple = (8, 8, 1, 1)
86 | asio: tuple = (6, 8)
87 | insert: int = 22
88 | composite: int = 8
89 |
90 |
91 | @dataclass(frozen=True)
92 | class PotatoMap(KindMapClass):
93 | ins: tuple = (5, 3)
94 | outs: tuple = (5, 3)
95 | vban: tuple = (8, 8, 1, 1)
96 | asio: tuple = (10, 8)
97 | insert: int = 34
98 | composite: int = 8
99 |
100 |
101 | def kind_factory(kind_id):
102 | match kind_id:
103 | case 'basic':
104 | _kind_map = BasicMap
105 | case 'banana':
106 | _kind_map = BananaMap
107 | case 'potato':
108 | _kind_map = PotatoMap
109 | case _:
110 | raise ValueError(f'Unknown Voicemeeter kind {kind_id}')
111 | return _kind_map(name=kind_id)
112 |
113 |
114 | def request_kind_map(kind_id):
115 | KIND_obj = None
116 | try:
117 | KIND_obj = kind_factory(kind_id)
118 | except ValueError as e:
119 | raise VMError(str(e)) from e
120 | return KIND_obj
121 |
122 |
123 | all = kinds_all = [request_kind_map(kind_id.name.lower()) for kind_id in KindId]
124 |
--------------------------------------------------------------------------------
/voicemeeterlib/macrobutton.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 |
3 | from .iremote import IRemote
4 |
5 | ButtonModes = IntEnum(
6 | 'ButtonModes',
7 | 'state stateonly trigger',
8 | start=1,
9 | )
10 |
11 |
12 | class Adapter(IRemote):
13 | """Adapter to the common interface."""
14 |
15 | def identifier(self):
16 | pass
17 |
18 | def getter(self, mode):
19 | self.logger.debug(f'getter: button[{self.index}].{ButtonModes(mode).name}')
20 | return self._remote.get_buttonstatus(self.index, mode)
21 |
22 | def setter(self, mode, val):
23 | self.logger.debug(
24 | f'setter: button[{self.index}].{ButtonModes(mode).name}={val}'
25 | )
26 | self._remote.set_buttonstatus(self.index, val, mode)
27 |
28 |
29 | class MacroButton(Adapter):
30 | """Defines concrete implementation for macrobutton"""
31 |
32 | def __str__(self):
33 | return f'{type(self).__name__}{self._remote.kind}{self.index}'
34 |
35 | @property
36 | def state(self) -> bool:
37 | return self.getter(ButtonModes.state) == 1
38 |
39 | @state.setter
40 | def state(self, val: bool):
41 | self.setter(ButtonModes.state, 1 if val else 0)
42 |
43 | @property
44 | def stateonly(self) -> bool:
45 | return self.getter(ButtonModes.stateonly) == 1
46 |
47 | @stateonly.setter
48 | def stateonly(self, val: bool):
49 | self.setter(ButtonModes.stateonly, 1 if val else 0)
50 |
51 | @property
52 | def trigger(self) -> bool:
53 | return self.getter(ButtonModes.trigger) == 1
54 |
55 | @trigger.setter
56 | def trigger(self, val: bool):
57 | self.setter(ButtonModes.trigger, 1 if val else 0)
58 |
--------------------------------------------------------------------------------
/voicemeeterlib/meta.py:
--------------------------------------------------------------------------------
1 | def bool_prop(param):
2 | """meta function for boolean parameters"""
3 |
4 | def fget(self) -> bool:
5 | return self.getter(param) == 1
6 |
7 | def fset(self, val: bool):
8 | self.setter(param, 1 if val else 0)
9 |
10 | return property(fget, fset)
11 |
12 |
13 | def float_prop(param):
14 | """meta function for float parameters"""
15 |
16 | def fget(self):
17 | return self.getter(param)
18 |
19 | def fset(self, val):
20 | self.setter(param, val)
21 |
22 | return property(fget, fset)
23 |
24 |
25 | def action_fn(param, val: int = 1):
26 | """meta function that performs an action"""
27 |
28 | def fdo(self):
29 | self.setter(param, val)
30 |
31 | return fdo
32 |
33 |
34 | def bus_mode_prop(param):
35 | """meta function for bus mode parameters"""
36 |
37 | def fget(self) -> bool:
38 | self._remote.clear_dirty()
39 | return self.getter(param) == 1
40 |
41 | def fset(self, val: bool):
42 | self.setter(param, 1 if val else 0)
43 |
44 | return property(fget, fset)
45 |
46 |
47 | def device_prop(param):
48 | """meta function for strip device parameters"""
49 |
50 | def fset(self, val: str):
51 | self.setter(param, val)
52 |
53 | return property(fset=fset)
54 |
--------------------------------------------------------------------------------
/voicemeeterlib/misc.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from . import kinds
4 | from .iremote import IRemote
5 |
6 |
7 | class FX(IRemote):
8 | def __str__(self):
9 | return f'{type(self).__name__}'
10 |
11 | @property
12 | def identifier(self) -> str:
13 | return 'FX'
14 |
15 | @property
16 | def reverb(self) -> bool:
17 | return self.getter('reverb.On') == 1
18 |
19 | @reverb.setter
20 | def reverb(self, val: bool):
21 | self.setter('reverb.On', 1 if val else 0)
22 |
23 | @property
24 | def reverb_ab(self) -> bool:
25 | return self.getter('reverb.ab') == 1
26 |
27 | @reverb_ab.setter
28 | def reverb_ab(self, val: bool):
29 | self.setter('reverb.ab', 1 if val else 0)
30 |
31 | @property
32 | def delay(self) -> bool:
33 | return self.getter('delay.On') == 1
34 |
35 | @delay.setter
36 | def delay(self, val: bool):
37 | self.setter('delay.On', 1 if val else 0)
38 |
39 | @property
40 | def delay_ab(self) -> bool:
41 | return self.getter('delay.ab') == 1
42 |
43 | @delay_ab.setter
44 | def delay_ab(self, val: bool):
45 | self.setter('delay.ab', 1 if val else 0)
46 |
47 |
48 | class Patch(IRemote):
49 | @classmethod
50 | def make(cls, remote):
51 | """
52 | Factory method for Patch.
53 |
54 | Mixes in required classes.
55 |
56 | Returns a Patch class of a kind.
57 | """
58 | ASIO_cls = _make_asio_mixins(remote)[remote.kind.name]
59 | return type(
60 | f'Patch{remote.kind}',
61 | (cls, ASIO_cls),
62 | {
63 | 'composite': tuple(Composite(remote, i) for i in range(8)),
64 | 'insert': tuple(Insert(remote, i) for i in range(remote.kind.insert)),
65 | },
66 | )(remote)
67 |
68 | def __str__(self):
69 | return f'{type(self).__name__}'
70 |
71 | @property
72 | def identifier(self) -> str:
73 | return 'patch'
74 |
75 | @property
76 | def postfadercomp(self) -> bool:
77 | return self.getter('postfadercomposite') == 1
78 |
79 | @postfadercomp.setter
80 | def postfadercomp(self, val: bool):
81 | self.setter('postfadercomposite', 1 if val else 0)
82 |
83 | @property
84 | def postfxinsert(self) -> bool:
85 | return self.getter('postfxinsert') == 1
86 |
87 | @postfxinsert.setter
88 | def postfxinsert(self, val: bool):
89 | self.setter('postfxinsert', 1 if val else 0)
90 |
91 |
92 | class Asio(IRemote):
93 | @property
94 | def identifier(self) -> str:
95 | return 'patch'
96 |
97 |
98 | class AsioIn(Asio):
99 | def get(self) -> int:
100 | return int(self.getter(f'asio[{self.index}]'))
101 |
102 | def set(self, val: int):
103 | self.setter(f'asio[{self.index}]', val)
104 |
105 |
106 | class AsioOut(Asio):
107 | def __init__(self, remote, i, param):
108 | IRemote.__init__(self, remote, i)
109 | self._param = param
110 |
111 | def get(self) -> int:
112 | return int(self.getter(f'out{self._param}[{self.index}]'))
113 |
114 | def set(self, val: int):
115 | self.setter(f'out{self._param}[{self.index}]', val)
116 |
117 |
118 | def _make_asio_mixin(remote, kind):
119 | """Creates an ASIO mixin for a kind"""
120 | asio_in, asio_out = kind.asio
121 |
122 | return type(
123 | f'ASIO{kind}',
124 | (IRemote,),
125 | {
126 | 'asio': tuple(AsioIn(remote, i) for i in range(asio_in)),
127 | **{
128 | param: tuple(AsioOut(remote, i, param) for i in range(asio_out))
129 | for param in ['A2', 'A3', 'A4', 'A5']
130 | },
131 | },
132 | )
133 |
134 |
135 | def _make_asio_mixins(remote):
136 | return {kind.name: _make_asio_mixin(remote, kind) for kind in kinds.all}
137 |
138 |
139 | class Composite(IRemote):
140 | @property
141 | def identifier(self) -> str:
142 | return 'patch'
143 |
144 | def get(self) -> int:
145 | return int(self.getter(f'composite[{self.index}]'))
146 |
147 | def set(self, val: int):
148 | self.setter(f'composite[{self.index}]', val)
149 |
150 |
151 | class Insert(IRemote):
152 | @property
153 | def identifier(self) -> str:
154 | return 'patch'
155 |
156 | @property
157 | def on(self) -> bool:
158 | return self.getter(f'insert[{self.index}]') == 1
159 |
160 | @on.setter
161 | def on(self, val: bool):
162 | self.setter(f'insert[{self.index}]', 1 if val else 0)
163 |
164 |
165 | class Option(IRemote):
166 | @classmethod
167 | def make(cls, remote):
168 | """
169 | Factory method for Option.
170 |
171 | Mixes in required classes.
172 |
173 | Returns a Option class of a kind.
174 | """
175 | return type(
176 | f'Option{remote.kind}',
177 | (cls,),
178 | {
179 | 'delay': tuple(Delay(remote, i) for i in range(remote.kind.phys_out)),
180 | },
181 | )(remote)
182 |
183 | def __str__(self):
184 | return f'{type(self).__name__}'
185 |
186 | @property
187 | def identifier(self) -> str:
188 | return 'option'
189 |
190 | @property
191 | def sr(self) -> int:
192 | return int(self.getter('sr'))
193 |
194 | @sr.setter
195 | def sr(self, val: int):
196 | opts = (44100, 48000, 88200, 96000, 176400, 192000)
197 | if val not in opts:
198 | self.logger.warning(f'sr got: {val} but expected a value in {opts}')
199 | self.setter('sr', val)
200 |
201 | @property
202 | def asiosr(self) -> bool:
203 | return self.getter('asiosr') == 1
204 |
205 | @asiosr.setter
206 | def asiosr(self, val: bool):
207 | self.setter('asiosr', 1 if val else 0)
208 |
209 | @property
210 | def monitoronsel(self) -> bool:
211 | return self.getter('monitoronsel') == 1
212 |
213 | @monitoronsel.setter
214 | def monitoronsel(self, val: bool):
215 | self.setter('monitoronsel', 1 if val else 0)
216 |
217 | def buffer(self, driver, buffer):
218 | self.setter(f'buffer.{driver}', buffer)
219 |
220 |
221 | class Delay(IRemote):
222 | @property
223 | def identifier(self) -> str:
224 | return 'option'
225 |
226 | def get(self) -> int:
227 | return int(self.getter(f'delay[{self.index}]'))
228 |
229 | def set(self, val: int):
230 | self.setter(f'delay[{self.index}]', val)
231 |
232 |
233 | class Midi:
234 | def __init__(self):
235 | self._channel = None
236 | self.cache = {}
237 | self._most_recent = None
238 |
239 | @property
240 | def channel(self) -> int:
241 | return self._channel
242 |
243 | @property
244 | def current(self) -> int:
245 | return self._most_recent
246 |
247 | def get(self, key: int) -> Optional[int]:
248 | return self.cache.get(key)
249 |
250 | def _set(self, key: int, velocity: int):
251 | self.cache[key] = velocity
252 |
253 |
254 | class VmGui:
255 | _launched = None
256 |
257 | @property
258 | def launched(self) -> bool:
259 | return self._launched
260 |
261 | @launched.setter
262 | def launched(self, val: bool):
263 | self._launched = val
264 |
265 | @property
266 | def launched_by_api(self):
267 | return not self.launched
268 |
--------------------------------------------------------------------------------
/voicemeeterlib/recorder.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from . import kinds
4 | from .error import VMError
5 | from .iremote import IRemote
6 | from .meta import action_fn, bool_prop
7 |
8 |
9 | class Recorder(IRemote):
10 | """
11 | Implements the common interface
12 |
13 | Defines concrete implementation for recorder
14 | """
15 |
16 | @classmethod
17 | def make(cls, remote):
18 | """
19 | Factory function for recorder.
20 |
21 | Returns a Recorder class of a kind.
22 | """
23 | CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
24 | ARMCHANNELMIXIN_cls = _make_armchannel_mixins(remote)[remote.kind.name]
25 | REC_cls = type(
26 | f'Recorder{remote.kind}',
27 | (cls, CHANNELOUTMIXIN_cls, ARMCHANNELMIXIN_cls),
28 | {
29 | **{
30 | param: action_fn(param)
31 | for param in [
32 | 'play',
33 | 'stop',
34 | 'pause',
35 | 'replay',
36 | 'record',
37 | 'ff',
38 | 'rew',
39 | ]
40 | },
41 | 'mode': RecorderMode(remote),
42 | },
43 | )
44 | return REC_cls(remote)
45 |
46 | def __str__(self):
47 | return f'{type(self).__name__}'
48 |
49 | @property
50 | def identifier(self) -> str:
51 | return 'recorder'
52 |
53 | @property
54 | def samplerate(self) -> int:
55 | return int(self.getter('samplerate'))
56 |
57 | @samplerate.setter
58 | def samplerate(self, val: int):
59 | opts = (22050, 24000, 32000, 44100, 48000, 88200, 96000, 176400, 192000)
60 | if val not in opts:
61 | self.logger.warning(f'samplerate got: {val} but expected a value in {opts}')
62 | self.setter('samplerate', val)
63 |
64 | @property
65 | def bitresolution(self) -> int:
66 | return int(self.getter('bitresolution'))
67 |
68 | @bitresolution.setter
69 | def bitresolution(self, val: int):
70 | opts = (8, 16, 24, 32)
71 | if val not in opts:
72 | self.logger.warning(
73 | f'bitresolution got: {val} but expected a value in {opts}'
74 | )
75 | self.setter('bitresolution', val)
76 |
77 | @property
78 | def channel(self) -> int:
79 | return int(self.getter('channel'))
80 |
81 | @channel.setter
82 | def channel(self, val: int):
83 | if not 1 <= val <= 8:
84 | self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
85 | self.setter('channel', val)
86 |
87 | @property
88 | def kbps(self):
89 | return int(self.getter('kbps'))
90 |
91 | @kbps.setter
92 | def kbps(self, val: int):
93 | opts = (32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
94 | if val not in opts:
95 | self.logger.warning(f'kbps got: {val} but expected a value in {opts}')
96 | self.setter('kbps', val)
97 |
98 | @property
99 | def gain(self) -> float:
100 | return round(self.getter('gain'), 1)
101 |
102 | @gain.setter
103 | def gain(self, val: float):
104 | self.setter('gain', val)
105 |
106 | def load(self, file: str):
107 | try:
108 | self.setter('load', file)
109 | except UnicodeError:
110 | raise VMError('File full directory must be a raw string')
111 |
112 | # loop forwarder methods, for backwards compatibility
113 | @property
114 | def loop(self):
115 | return self.mode.loop
116 |
117 | @loop.setter
118 | def loop(self, val: bool):
119 | self.mode.loop = val
120 |
121 | def goto(self, time_str):
122 | def get_sec():
123 | """Get seconds from time string"""
124 | h, m, s = time_str.split(':')
125 | return int(h) * 3600 + int(m) * 60 + int(s)
126 |
127 | time_str = str(time_str) # coerce the type
128 | if (
129 | re.match(
130 | r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$',
131 | time_str,
132 | )
133 | is not None
134 | ):
135 | self.setter('goto', get_sec())
136 | else:
137 | self.logger.warning(
138 | "goto expects a string that matches the format 'hh:mm:ss'"
139 | )
140 |
141 | def filetype(self, val: str):
142 | opts = {'wav': 1, 'aiff': 2, 'bwf': 3, 'mp3': 100}
143 | try:
144 | self.setter('filetype', opts[val.lower()])
145 | except KeyError:
146 | self.logger.warning(
147 | f'filetype got: {val} but expected a value in {list(opts.keys())}'
148 | )
149 |
150 |
151 | class RecorderMode(IRemote):
152 | @property
153 | def identifier(self):
154 | return 'recorder.mode'
155 |
156 | @property
157 | def recbus(self) -> bool:
158 | return self.getter('recbus') == 1
159 |
160 | @recbus.setter
161 | def recbus(self, val: bool):
162 | self.setter('recbus', 1 if val else 0)
163 |
164 | @property
165 | def playonload(self) -> bool:
166 | return self.getter('playonload') == 1
167 |
168 | @playonload.setter
169 | def playonload(self, val: bool):
170 | self.setter('playonload', 1 if val else 0)
171 |
172 | @property
173 | def loop(self) -> bool:
174 | return self.getter('loop') == 1
175 |
176 | @loop.setter
177 | def loop(self, val: bool):
178 | self.setter('loop', 1 if val else 0)
179 |
180 | @property
181 | def multitrack(self) -> bool:
182 | return self.getter('multitrack') == 1
183 |
184 | @multitrack.setter
185 | def multitrack(self, val: bool):
186 | self.setter('multitrack', 1 if val else 0)
187 |
188 |
189 | class RecorderArmChannel(IRemote):
190 | def __init__(self, remote, i):
191 | super().__init__(remote)
192 | self._i = i
193 |
194 | def set(self, val: bool):
195 | self.setter('', 1 if val else 0)
196 |
197 |
198 | class RecorderArmStrip(RecorderArmChannel):
199 | @property
200 | def identifier(self):
201 | return f'recorder.armstrip[{self._i}]'
202 |
203 |
204 | class RecorderArmBus(RecorderArmChannel):
205 | @property
206 | def identifier(self):
207 | return f'recorder.armbus[{self._i}]'
208 |
209 |
210 | def _make_armchannel_mixin(remote, kind):
211 | """Creates an armchannel out mixin"""
212 | return type(
213 | f'ArmChannelMixin{kind}',
214 | (),
215 | {
216 | 'armstrip': tuple(
217 | RecorderArmStrip(remote, i) for i in range(kind.num_strip)
218 | ),
219 | 'armbus': tuple(RecorderArmBus(remote, i) for i in range(kind.num_bus)),
220 | },
221 | )
222 |
223 |
224 | def _make_armchannel_mixins(remote):
225 | return {kind.name: _make_armchannel_mixin(remote, kind) for kind in kinds.all}
226 |
227 |
228 | def _make_channelout_mixin(kind):
229 | """Creates a channel out mixin"""
230 | return type(
231 | f'ChannelOutMixin{kind}',
232 | (),
233 | {
234 | **{f'A{i}': bool_prop(f'A{i}') for i in range(1, kind.phys_out + 1)},
235 | **{f'B{i}': bool_prop(f'B{i}') for i in range(1, kind.virt_out + 1)},
236 | },
237 | )
238 |
239 |
240 | _make_channelout_mixins = {
241 | kind.name: _make_channelout_mixin(kind) for kind in kinds.all
242 | }
243 |
--------------------------------------------------------------------------------
/voicemeeterlib/remote.py:
--------------------------------------------------------------------------------
1 | import ctypes as ct
2 | import logging
3 | import threading
4 | import time
5 | from abc import abstractmethod
6 | from queue import Queue
7 | from typing import Iterable, Optional, Union
8 |
9 | from .cbindings import CBindings
10 | from .error import CAPIError, VMError
11 | from .event import Event
12 | from .inst import BITS
13 | from .kinds import KindId
14 | from .misc import Midi, VmGui
15 | from .subject import Subject
16 | from .updater import Producer, Updater
17 | from .util import deep_merge, grouper, polling, script, timeout
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 |
22 | class Remote(CBindings):
23 | """Base class responsible for wrapping the C Remote API"""
24 |
25 | DELAY = 0.001
26 |
27 | def __init__(self, **kwargs):
28 | self.strip_mode = 0
29 | self.cache = {}
30 | self.midi = Midi()
31 | self.subject = self.observer = Subject()
32 | self.event = Event(
33 | {k: kwargs.pop(k) for k in ('pdirty', 'mdirty', 'midi', 'ldirty')}
34 | )
35 | self.gui = VmGui()
36 | self.stop_event = None
37 | self.logger = logger.getChild(self.__class__.__name__)
38 |
39 | for attr, val in kwargs.items():
40 | setattr(self, attr, val)
41 |
42 | if self.bits not in (32, 64):
43 | self.logger.warning(
44 | f'kwarg bits got {self.bits}, expected either 32 or 64, defaulting to 64'
45 | )
46 | self.bits = 64
47 |
48 | def __enter__(self):
49 | """setup procedures"""
50 | self.login()
51 | if self.event.any():
52 | self.init_thread()
53 | return self
54 |
55 | @abstractmethod
56 | def __str__(self):
57 | """Ensure subclasses override str magic method"""
58 | pass
59 |
60 | def init_thread(self):
61 | """Starts updates thread."""
62 | self.event.info()
63 |
64 | self.logger.debug('initiating events thread')
65 | self.stop_event = threading.Event()
66 | self.stop_event.clear()
67 | queue = Queue()
68 | self.updater = Updater(self, queue)
69 | self.updater.start()
70 | self.producer = Producer(self, queue, self.stop_event)
71 | self.producer.start()
72 |
73 | def stopped(self):
74 | return self.stop_event is None or self.stop_event.is_set()
75 |
76 | @timeout
77 | def login(self) -> None:
78 | """Login to the API, initialize dirty parameters"""
79 | self.gui.launched = self.call(self.bind_login, ok=(0, 1)) == 0
80 | if not self.gui.launched:
81 | self.logger.info(
82 | 'Voicemeeter engine running but GUI not launched. Launching the GUI now.'
83 | )
84 | self.run_voicemeeter(self.kind.name)
85 |
86 | def run_voicemeeter(self, kind_id: str) -> None:
87 | if kind_id not in (kind.name.lower() for kind in KindId):
88 | raise VMError(f"Unexpected Voicemeeter type: '{kind_id}'")
89 | value = KindId[kind_id.upper()].value
90 | if BITS == 64 and self.bits == 64:
91 | value += 3
92 | self.call(self.bind_run_voicemeeter, value)
93 |
94 | @property
95 | def type(self) -> str:
96 | """Returns the type of Voicemeeter installation (basic, banana, potato)."""
97 | type_ = ct.c_long()
98 | self.call(self.bind_get_voicemeeter_type, ct.byref(type_))
99 | return KindId(type_.value).name.lower()
100 |
101 | @property
102 | def version(self) -> str:
103 | """Returns Voicemeeter's version as a string"""
104 | ver = ct.c_long()
105 | self.call(self.bind_get_voicemeeter_version, ct.byref(ver))
106 | return '{}.{}.{}.{}'.format(
107 | (ver.value & 0xFF000000) >> 24,
108 | (ver.value & 0x00FF0000) >> 16,
109 | (ver.value & 0x0000FF00) >> 8,
110 | ver.value & 0x000000FF,
111 | )
112 |
113 | @property
114 | def pdirty(self) -> bool:
115 | """True iff UI parameters have been updated."""
116 | return self.call(self.bind_is_parameters_dirty, ok=(0, 1)) == 1
117 |
118 | @property
119 | def mdirty(self) -> bool:
120 | """True iff MB parameters have been updated."""
121 | try:
122 | return self.call(self.bind_macro_button_is_dirty, ok=(0, 1)) == 1
123 | except AttributeError as e:
124 | self.logger.exception(f'{type(e).__name__}: {e}')
125 | raise CAPIError('VBVMR_MacroButton_IsDirty', -9) from e
126 |
127 | @property
128 | def ldirty(self) -> bool:
129 | """True iff levels have been updated."""
130 | self._strip_buf, self._bus_buf = self._get_levels()
131 | return not (
132 | self.cache.get('strip_level') == self._strip_buf
133 | and self.cache.get('bus_level') == self._bus_buf
134 | )
135 |
136 | def clear_dirty(self) -> None:
137 | try:
138 | while self.pdirty or self.mdirty:
139 | pass
140 | except CAPIError as e:
141 | if not (e.fn_name == 'VBVMR_MacroButton_IsDirty' and e.code == -9):
142 | raise
143 | self.logger.error(f'{e} clearing pdirty only.')
144 | while self.pdirty:
145 | pass
146 |
147 | @polling
148 | def get(self, param: str, is_string: Optional[bool] = False) -> Union[str, float]:
149 | """Gets a string or float parameter"""
150 | if is_string:
151 | buf = ct.create_unicode_buffer(512)
152 | self.call(self.bind_get_parameter_string_w, param.encode(), ct.byref(buf))
153 | else:
154 | buf = ct.c_float()
155 | self.call(self.bind_get_parameter_float, param.encode(), ct.byref(buf))
156 | return buf.value
157 |
158 | def set(self, param: str, val: Union[str, float]) -> None:
159 | """Sets a string or float parameter. Caches value"""
160 | if isinstance(val, str):
161 | if len(val) >= 512:
162 | raise VMError('String is too long')
163 | self.call(
164 | self.bind_set_parameter_string_w, param.encode(), ct.c_wchar_p(val)
165 | )
166 | else:
167 | self.call(
168 | self.bind_set_parameter_float, param.encode(), ct.c_float(float(val))
169 | )
170 | self.cache[param] = val
171 |
172 | @polling
173 | def get_buttonstatus(self, id_: int, mode: int) -> int:
174 | """Gets a macrobutton parameter"""
175 | c_state = ct.c_float()
176 | try:
177 | self.call(
178 | self.bind_macro_button_get_status,
179 | ct.c_long(id_),
180 | ct.byref(c_state),
181 | ct.c_long(mode),
182 | )
183 | except AttributeError as e:
184 | self.logger.exception(f'{type(e).__name__}: {e}')
185 | raise CAPIError('VBVMR_MacroButton_GetStatus', -9) from e
186 | return int(c_state.value)
187 |
188 | def set_buttonstatus(self, id_: int, val: int, mode: int) -> None:
189 | """Sets a macrobutton parameter. Caches value"""
190 | c_state = ct.c_float(float(val))
191 | try:
192 | self.call(
193 | self.bind_macro_button_set_status,
194 | ct.c_long(id_),
195 | c_state,
196 | ct.c_long(mode),
197 | )
198 | except AttributeError as e:
199 | self.logger.exception(f'{type(e).__name__}: {e}')
200 | raise CAPIError('VBVMR_MacroButton_SetStatus', -9) from e
201 | self.cache[f'mb_{id_}_{mode}'] = int(c_state.value)
202 |
203 | def get_num_devices(self, direction: str = None) -> int:
204 | """Retrieves number of physical devices connected"""
205 | if direction not in ('in', 'out'):
206 | raise VMError('Expected a direction: in or out')
207 | func = getattr(self, f'bind_{direction}put_get_device_number')
208 | res = self.call(func, ok_exp=lambda r: r >= 0)
209 | return res
210 |
211 | def get_device_description(self, index: int, direction: str = None) -> tuple:
212 | """Returns a tuple of device parameters"""
213 | if direction not in ('in', 'out'):
214 | raise VMError('Expected a direction: in or out')
215 | type_ = ct.c_long()
216 | name = ct.create_unicode_buffer(256)
217 | hwid = ct.create_unicode_buffer(256)
218 | func = getattr(self, f'bind_{direction}put_get_device_desc_w')
219 | self.call(
220 | func,
221 | ct.c_long(index),
222 | ct.byref(type_),
223 | ct.byref(name),
224 | ct.byref(hwid),
225 | )
226 | return (name.value, type_.value, hwid.value)
227 |
228 | def get_level(self, type_: int, index: int) -> float:
229 | """Retrieves a single level value"""
230 | val = ct.c_float()
231 | self.call(
232 | self.bind_get_level, ct.c_long(type_), ct.c_long(index), ct.byref(val)
233 | )
234 | return val.value
235 |
236 | def _get_levels(self) -> Iterable:
237 | """
238 | returns both level arrays (strip_levels, bus_levels) BEFORE math conversion
239 | """
240 | return (
241 | tuple(
242 | self.get_level(self.strip_mode, i)
243 | for i in range(self.kind.num_strip_levels)
244 | ),
245 | tuple(self.get_level(3, i) for i in range(self.kind.num_bus_levels)),
246 | )
247 |
248 | def get_midi_message(self):
249 | n = ct.c_long(1024)
250 | buf = ct.create_string_buffer(1024)
251 | res = self.call(
252 | self.bind_get_midi_message,
253 | ct.byref(buf),
254 | n,
255 | ok=(-5, -6), # no data received from midi device
256 | ok_exp=lambda r: r >= 0,
257 | )
258 | if res > 0:
259 | vals = tuple(
260 | grouper(3, (int.from_bytes(buf[i], 'little') for i in range(res)))
261 | )
262 | for msg in vals:
263 | ch, pitch, vel = msg
264 | if not self.midi._channel or self.midi._channel != ch:
265 | self.midi._channel = ch
266 | self.midi._most_recent = pitch
267 | self.midi._set(pitch, vel)
268 | return True
269 |
270 | @script
271 | def sendtext(self, script: str):
272 | """Sets many parameters from a script"""
273 | if len(script) > 48000:
274 | raise ValueError('Script too large, max size 48kB')
275 | self.call(self.bind_set_parameters, script.encode())
276 | time.sleep(self.DELAY * 5)
277 |
278 | def apply(self, data: dict):
279 | """
280 | Sets all parameters of a dict
281 |
282 | minor delay between each recursion
283 | """
284 |
285 | def target(key):
286 | match key.split('-'):
287 | case ['strip' | 'bus' | 'button' as kls, index] if index.isnumeric():
288 | target = getattr(self, kls)
289 | case [
290 | 'vban',
291 | 'in'
292 | | 'instream'
293 | | 'out'
294 | | 'outstream' as direction,
295 | index,
296 | ] if index.isnumeric():
297 | target = getattr(
298 | self.vban, f"{direction.removesuffix('stream')}stream"
299 | )
300 | case _:
301 | ERR_MSG = f"invalid config key '{key}'"
302 | self.logger.error(ERR_MSG)
303 | raise ValueError(ERR_MSG)
304 | return target[int(index)]
305 |
306 | [target(key).apply(di).then_wait() for key, di in data.items()]
307 |
308 | def apply_config(self, name):
309 | """applies a config from memory"""
310 | ERR_MSG = (
311 | f"No config with name '{name}' is loaded into memory",
312 | f'Known configs: {list(self.configs.keys())}',
313 | )
314 | try:
315 | config = self.configs[name]
316 | except KeyError as e:
317 | self.logger.error(('\n').join(ERR_MSG))
318 | raise VMError(('\n').join(ERR_MSG)) from e
319 |
320 | if 'extends' in config:
321 | extended = config['extends']
322 | config = {
323 | k: v
324 | for k, v in deep_merge(self.configs[extended], config)
325 | if k not in ('extends')
326 | }
327 | self.logger.debug(
328 | f"profile '{name}' extends '{extended}', profiles merged.."
329 | )
330 | self.apply(config)
331 | self.logger.info(f"Profile '{name}' applied!")
332 |
333 | def end_thread(self):
334 | if not self.stopped():
335 | self.logger.debug('events thread shutdown started')
336 | self.stop_event.set()
337 | self.producer.join() # wait for producer thread to complete cycle
338 |
339 | def logout(self) -> None:
340 | """Logout of the API"""
341 | time.sleep(0.1)
342 | self.call(self.bind_logout)
343 | self.logger.info(f'{type(self).__name__}: Successfully logged out of {self}')
344 |
345 | def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
346 | """teardown procedures"""
347 | self.end_thread()
348 | self.logout()
349 |
--------------------------------------------------------------------------------
/voicemeeterlib/strip.py:
--------------------------------------------------------------------------------
1 | import time
2 | from abc import abstractmethod
3 | from math import log
4 | from typing import Union
5 |
6 | from . import kinds
7 | from .iremote import IRemote
8 | from .meta import bool_prop, device_prop, float_prop
9 |
10 |
11 | class Strip(IRemote):
12 | """
13 | Implements the common interface
14 |
15 | Defines concrete implementation for strip
16 | """
17 |
18 | @abstractmethod
19 | def __str__(self):
20 | pass
21 |
22 | @property
23 | def identifier(self) -> str:
24 | return f'strip[{self.index}]'
25 |
26 | @property
27 | def mono(self) -> bool:
28 | return self.getter('mono') == 1
29 |
30 | @mono.setter
31 | def mono(self, val: bool):
32 | self.setter('mono', 1 if val else 0)
33 |
34 | @property
35 | def solo(self) -> bool:
36 | return self.getter('solo') == 1
37 |
38 | @solo.setter
39 | def solo(self, val: bool):
40 | self.setter('solo', 1 if val else 0)
41 |
42 | @property
43 | def mute(self) -> bool:
44 | return self.getter('mute') == 1
45 |
46 | @mute.setter
47 | def mute(self, val: bool):
48 | self.setter('mute', 1 if val else 0)
49 |
50 | @property
51 | def limit(self) -> int:
52 | return int(self.getter('limit'))
53 |
54 | @limit.setter
55 | def limit(self, val: int):
56 | self.setter('limit', val)
57 |
58 | @property
59 | def label(self) -> str:
60 | return self.getter('Label', is_string=True)
61 |
62 | @label.setter
63 | def label(self, val: str):
64 | self.setter('Label', str(val))
65 |
66 | @property
67 | def gain(self) -> float:
68 | return round(self.getter('gain'), 1)
69 |
70 | @gain.setter
71 | def gain(self, val: float):
72 | self.setter('gain', val)
73 |
74 | def fadeto(self, target: float, time_: int):
75 | self.setter('FadeTo', f'({target}, {time_})')
76 | time.sleep(self._remote.DELAY)
77 |
78 | def fadeby(self, change: float, time_: int):
79 | self.setter('FadeBy', f'({change}, {time_})')
80 | time.sleep(self._remote.DELAY)
81 |
82 |
83 | class PhysicalStrip(Strip):
84 | @classmethod
85 | def make(cls, remote, i, is_phys):
86 | """
87 | Factory method for PhysicalStrip.
88 |
89 | Returns a PhysicalStrip class.
90 | """
91 | EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
92 | return type(
93 | 'PhysicalStrip',
94 | (cls, EFFECTS_cls),
95 | {
96 | 'comp': StripComp(remote, i),
97 | 'gate': StripGate(remote, i),
98 | 'denoiser': StripDenoiser(remote, i),
99 | 'eq': StripEQ(remote, i),
100 | 'device': StripDevice.make(remote, i),
101 | },
102 | )
103 |
104 | def __str__(self):
105 | return f'{type(self).__name__}{self.index}'
106 |
107 | @property
108 | def audibility(self) -> float:
109 | return round(self.getter('audibility'), 1)
110 |
111 | @audibility.setter
112 | def audibility(self, val: float):
113 | self.setter('audibility', val)
114 |
115 |
116 | class StripComp(IRemote):
117 | @property
118 | def identifier(self) -> str:
119 | return f'Strip[{self.index}].comp'
120 |
121 | @property
122 | def knob(self) -> float:
123 | return round(self.getter(''), 1)
124 |
125 | @knob.setter
126 | def knob(self, val: float):
127 | self.setter('', val)
128 |
129 | @property
130 | def gainin(self) -> float:
131 | return round(self.getter('GainIn'), 1)
132 |
133 | @gainin.setter
134 | def gainin(self, val: float):
135 | self.setter('GainIn', val)
136 |
137 | @property
138 | def ratio(self) -> float:
139 | return round(self.getter('Ratio'), 1)
140 |
141 | @ratio.setter
142 | def ratio(self, val: float):
143 | self.setter('Ratio', val)
144 |
145 | @property
146 | def threshold(self) -> float:
147 | return round(self.getter('Threshold'), 1)
148 |
149 | @threshold.setter
150 | def threshold(self, val: float):
151 | self.setter('Threshold', val)
152 |
153 | @property
154 | def attack(self) -> float:
155 | return round(self.getter('Attack'), 1)
156 |
157 | @attack.setter
158 | def attack(self, val: float):
159 | self.setter('Attack', val)
160 |
161 | @property
162 | def release(self) -> float:
163 | return round(self.getter('Release'), 1)
164 |
165 | @release.setter
166 | def release(self, val: float):
167 | self.setter('Release', val)
168 |
169 | @property
170 | def knee(self) -> float:
171 | return round(self.getter('Knee'), 2)
172 |
173 | @knee.setter
174 | def knee(self, val: float):
175 | self.setter('Knee', val)
176 |
177 | @property
178 | def gainout(self) -> float:
179 | return round(self.getter('GainOut'), 1)
180 |
181 | @gainout.setter
182 | def gainout(self, val: float):
183 | self.setter('GainOut', val)
184 |
185 | @property
186 | def makeup(self) -> bool:
187 | return self.getter('makeup') == 1
188 |
189 | @makeup.setter
190 | def makeup(self, val: bool):
191 | self.setter('makeup', 1 if val else 0)
192 |
193 |
194 | class StripGate(IRemote):
195 | @property
196 | def identifier(self) -> str:
197 | return f'Strip[{self.index}].gate'
198 |
199 | @property
200 | def knob(self) -> float:
201 | return round(self.getter(''), 1)
202 |
203 | @knob.setter
204 | def knob(self, val: float):
205 | self.setter('', val)
206 |
207 | @property
208 | def threshold(self) -> float:
209 | return round(self.getter('Threshold'), 1)
210 |
211 | @threshold.setter
212 | def threshold(self, val: float):
213 | self.setter('Threshold', val)
214 |
215 | @property
216 | def damping(self) -> float:
217 | return round(self.getter('Damping'), 1)
218 |
219 | @damping.setter
220 | def damping(self, val: float):
221 | self.setter('Damping', val)
222 |
223 | @property
224 | def bpsidechain(self) -> int:
225 | return int(self.getter('BPSidechain'))
226 |
227 | @bpsidechain.setter
228 | def bpsidechain(self, val: int):
229 | self.setter('BPSidechain', val)
230 |
231 | @property
232 | def attack(self) -> float:
233 | return round(self.getter('Attack'), 1)
234 |
235 | @attack.setter
236 | def attack(self, val: float):
237 | self.setter('Attack', val)
238 |
239 | @property
240 | def hold(self) -> float:
241 | return round(self.getter('Hold'), 1)
242 |
243 | @hold.setter
244 | def hold(self, val: float):
245 | self.setter('Hold', val)
246 |
247 | @property
248 | def release(self) -> float:
249 | return round(self.getter('Release'), 1)
250 |
251 | @release.setter
252 | def release(self, val: float):
253 | self.setter('Release', val)
254 |
255 |
256 | class StripDenoiser(IRemote):
257 | @property
258 | def identifier(self) -> str:
259 | return f'Strip[{self.index}].denoiser'
260 |
261 | @property
262 | def knob(self) -> float:
263 | return round(self.getter(''), 1)
264 |
265 | @knob.setter
266 | def knob(self, val: float):
267 | self.setter('', val)
268 |
269 |
270 | class StripEQ(IRemote):
271 | @property
272 | def identifier(self) -> str:
273 | return f'Strip[{self.index}].eq'
274 |
275 | @property
276 | def on(self) -> bool:
277 | return self.getter('on') == 1
278 |
279 | @on.setter
280 | def on(self, val: bool):
281 | self.setter('on', 1 if val else 0)
282 |
283 | @property
284 | def ab(self) -> bool:
285 | return self.getter('ab') == 1
286 |
287 | @ab.setter
288 | def ab(self, val: bool):
289 | self.setter('ab', 1 if val else 0)
290 |
291 |
292 | class StripDevice(IRemote):
293 | @classmethod
294 | def make(cls, remote, i):
295 | """
296 | Factory function for strip.device.
297 |
298 | Returns a StripDevice class of a kind.
299 | """
300 | DEVICE_cls = type(
301 | f'StripDevice{remote.kind}',
302 | (cls,),
303 | {
304 | **{
305 | param: device_prop(param)
306 | for param in [
307 | 'wdm',
308 | 'ks',
309 | 'mme',
310 | 'asio',
311 | ]
312 | },
313 | },
314 | )
315 | return DEVICE_cls(remote, i)
316 |
317 | @property
318 | def identifier(self) -> str:
319 | return f'Strip[{self.index}].device'
320 |
321 | @property
322 | def name(self) -> str:
323 | return self.getter('name', is_string=True)
324 |
325 | @property
326 | def sr(self) -> int:
327 | return int(self.getter('sr'))
328 |
329 |
330 | class VirtualStrip(Strip):
331 | @classmethod
332 | def make(cls, remote, i, is_phys):
333 | """
334 | Factory method for VirtualStrip.
335 |
336 | Returns a VirtualStrip class.
337 | """
338 | EFFECTS_cls = _make_effects_mixins(is_phys)[remote.kind.name]
339 | return type(
340 | 'VirtualStrip',
341 | (cls, EFFECTS_cls),
342 | {},
343 | )
344 |
345 | def __str__(self):
346 | return f'{type(self).__name__}{self.index}'
347 |
348 | @property
349 | def mc(self) -> bool:
350 | return self.getter('mc') == 1
351 |
352 | @mc.setter
353 | def mc(self, val: bool):
354 | self.setter('mc', 1 if val else 0)
355 |
356 | mono = mc
357 |
358 | @property
359 | def k(self) -> int:
360 | return int(self.getter('karaoke'))
361 |
362 | @k.setter
363 | def k(self, val: int):
364 | self.setter('karaoke', val)
365 |
366 | @property
367 | def bass(self) -> float:
368 | return round(self.getter('EQGain1'), 1)
369 |
370 | @bass.setter
371 | def bass(self, val: float):
372 | self.setter('EQGain1', val)
373 |
374 | @property
375 | def mid(self) -> float:
376 | return round(self.getter('EQGain2'), 1)
377 |
378 | @mid.setter
379 | def mid(self, val: float):
380 | self.setter('EQGain2', val)
381 |
382 | med = mid
383 |
384 | @property
385 | def treble(self) -> float:
386 | return round(self.getter('EQGain3'), 1)
387 |
388 | high = treble
389 |
390 | @treble.setter
391 | def treble(self, val: float):
392 | self.setter('EQGain3', val)
393 |
394 | def appgain(self, name: str, gain: float):
395 | self.setter('AppGain', f'("{name}", {gain})')
396 |
397 | def appmute(self, name: str, mute: bool = None):
398 | self.setter('AppMute', f'("{name}", {1 if mute else 0})')
399 |
400 |
401 | class StripLevel(IRemote):
402 | def __init__(self, remote, index):
403 | super().__init__(remote, index)
404 | self.range = _make_strip_level_maps[remote.kind.name][self.index]
405 |
406 | def getter(self, mode):
407 | """
408 | Returns a tuple of level values for the channel.
409 |
410 | If observables thread running and level updates are subscribed to, fetch values from cache
411 |
412 | Otherwise call CAPI func.
413 | """
414 |
415 | def fget(x):
416 | return round(20 * log(x, 10), 1) if x > 0 else -200.0
417 |
418 | if not self._remote.stopped() and self._remote.event.ldirty:
419 | vals = self._remote.cache['strip_level'][self.range[0] : self.range[-1]]
420 | else:
421 | vals = [self._remote.get_level(mode, i) for i in range(*self.range)]
422 |
423 | return tuple(fget(val) for val in vals)
424 |
425 | @property
426 | def identifier(self) -> str:
427 | return f'Strip[{self.index}]'
428 |
429 | @property
430 | def prefader(self) -> tuple:
431 | self._remote.strip_mode = 0
432 | return self.getter(0)
433 |
434 | @property
435 | def postfader(self) -> tuple:
436 | self._remote.strip_mode = 1
437 | return self.getter(1)
438 |
439 | @property
440 | def postmute(self) -> tuple:
441 | self._remote.strip_mode = 2
442 | return self.getter(2)
443 |
444 | @property
445 | def isdirty(self) -> bool:
446 | """
447 | Returns dirty status for this specific channel.
448 |
449 | Expected to be used in a callback only.
450 | """
451 | if not self._remote.stopped():
452 | return any(self._remote._strip_comp[self.range[0] : self.range[-1]])
453 |
454 | is_updated = isdirty
455 |
456 |
457 | def make_strip_level_map(kind):
458 | phys_map = tuple((i, i + 2) for i in range(0, kind.phys_in * 2, 2))
459 | virt_map = tuple(
460 | (i, i + 8)
461 | for i in range(
462 | kind.phys_in * 2,
463 | kind.phys_in * 2 + kind.virt_in * 8,
464 | 8,
465 | )
466 | )
467 | return phys_map + virt_map
468 |
469 |
470 | _make_strip_level_maps = {kind.name: make_strip_level_map(kind) for kind in kinds.all}
471 |
472 |
473 | class GainLayer(IRemote):
474 | def __init__(self, remote, index, i):
475 | super().__init__(remote, index)
476 | self._i = i
477 |
478 | @property
479 | def identifier(self) -> str:
480 | return f'Strip[{self.index}]'
481 |
482 | @property
483 | def gain(self):
484 | return self.getter(f'GainLayer[{self._i}]')
485 |
486 | @gain.setter
487 | def gain(self, val):
488 | self.setter(f'GainLayer[{self._i}]', val)
489 |
490 |
491 | def _make_gainlayer_mixin(remote, index):
492 | """Creates a GainLayer mixin"""
493 | return type(
494 | 'GainlayerMixin',
495 | (),
496 | {
497 | 'gainlayer': tuple(
498 | GainLayer(remote, index, i) for i in range(remote.kind.num_bus)
499 | )
500 | },
501 | )
502 |
503 |
504 | def _make_channelout_mixin(kind):
505 | """Creates a channel out property mixin"""
506 | return type(
507 | f'ChannelOutMixin{kind}',
508 | (),
509 | {
510 | **{f'A{i}': bool_prop(f'A{i}') for i in range(1, kind.phys_out + 1)},
511 | **{f'B{i}': bool_prop(f'B{i}') for i in range(1, kind.virt_out + 1)},
512 | },
513 | )
514 |
515 |
516 | _make_channelout_mixins = {
517 | kind.name: _make_channelout_mixin(kind) for kind in kinds.all
518 | }
519 |
520 |
521 | def _make_effects_mixin(kind, is_phys):
522 | """creates an effects mixin for a kind"""
523 |
524 | def _make_xy_cls():
525 | pan = {param: float_prop(param) for param in ['pan_x', 'pan_y']}
526 | color = {param: float_prop(param) for param in ['color_x', 'color_y']}
527 | fx = {param: float_prop(param) for param in ['fx_x', 'fx_y']}
528 | if is_phys:
529 | return type(
530 | 'XYPhys',
531 | (),
532 | {
533 | **pan,
534 | **color,
535 | **fx,
536 | },
537 | )
538 | return type(
539 | 'XYVirt',
540 | (),
541 | {**pan},
542 | )
543 |
544 | def _make_fx_cls():
545 | if is_phys:
546 | return type(
547 | 'FX',
548 | (),
549 | {
550 | **{
551 | param: float_prop(param)
552 | for param in ['reverb', 'delay', 'fx1', 'fx2']
553 | },
554 | **{
555 | f'post{param}': bool_prop(f'post{param}')
556 | for param in ['reverb', 'delay', 'fx1', 'fx2']
557 | },
558 | },
559 | )
560 | return type('FX', (), {})
561 |
562 | if kind.name == 'basic':
563 | steps = (_make_xy_cls,)
564 | elif kind.name == 'banana':
565 | steps = (_make_xy_cls,)
566 | elif kind.name == 'potato':
567 | steps = (_make_xy_cls, _make_fx_cls)
568 | return type(f'Effects{kind}', tuple(step() for step in steps), {})
569 |
570 |
571 | def _make_effects_mixins(is_phys):
572 | return {kind.name: _make_effects_mixin(kind, is_phys) for kind in kinds.all}
573 |
574 |
575 | def strip_factory(is_phys_strip, remote, i) -> Union[PhysicalStrip, VirtualStrip]:
576 | """
577 | Factory method for strips
578 |
579 | Mixes in required classes
580 |
581 | Returns a physical or virtual strip subclass
582 | """
583 | STRIP_cls = (
584 | PhysicalStrip.make(remote, i, is_phys_strip)
585 | if is_phys_strip
586 | else VirtualStrip.make(remote, i, is_phys_strip)
587 | )
588 | CHANNELOUTMIXIN_cls = _make_channelout_mixins[remote.kind.name]
589 |
590 | _kls = (STRIP_cls, CHANNELOUTMIXIN_cls)
591 | if remote.kind.name == 'potato':
592 | GAINLAYERMIXIN_cls = _make_gainlayer_mixin(remote, i)
593 | _kls += (GAINLAYERMIXIN_cls,)
594 | return type(
595 | f'{STRIP_cls.__name__}{remote.kind}',
596 | _kls,
597 | {
598 | 'levels': StripLevel(remote, i),
599 | },
600 | )(remote, i)
601 |
602 |
603 | def request_strip_obj(is_phys_strip, remote, i) -> Strip:
604 | """
605 | Strip entry point. Wraps factory method.
606 |
607 | Returns a reference to a strip subclass of a kind
608 | """
609 | return strip_factory(is_phys_strip, remote, i)
610 |
--------------------------------------------------------------------------------
/voicemeeterlib/subject.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | logger = logging.getLogger(__name__)
4 |
5 |
6 | class Subject:
7 | def __init__(self):
8 | """Adds support for observers and callbacks"""
9 |
10 | self._observers = list()
11 | self.logger = logger.getChild(self.__class__.__name__)
12 |
13 | @property
14 | def observers(self) -> list:
15 | """returns the current observers"""
16 |
17 | return self._observers
18 |
19 | def notify(self, event):
20 | """run callbacks on update"""
21 |
22 | for o in self._observers:
23 | if hasattr(o, 'on_update'):
24 | o.on_update(event)
25 | else:
26 | if o.__name__ == f'on_{event}':
27 | o()
28 |
29 | def add(self, observer):
30 | """adds an observer to observers"""
31 |
32 | try:
33 | iterator = iter(observer)
34 | for o in iterator:
35 | if o not in self._observers:
36 | self._observers.append(o)
37 | self.logger.info(f'{o} added to event observers')
38 | else:
39 | self.logger.error(f'Failed to add {o} to event observers')
40 | except TypeError:
41 | if observer not in self._observers:
42 | self._observers.append(observer)
43 | self.logger.info(f'{observer} added to event observers')
44 | else:
45 | self.logger.error(f'Failed to add {observer} to event observers')
46 |
47 | register = add
48 |
49 | def remove(self, observer):
50 | """removes an observer from observers"""
51 |
52 | try:
53 | iterator = iter(observer)
54 | for o in iterator:
55 | try:
56 | self._observers.remove(o)
57 | self.logger.info(f'{o} removed from event observers')
58 | except ValueError:
59 | self.logger.error(f'Failed to remove {o} from event observers')
60 | except TypeError:
61 | try:
62 | self._observers.remove(observer)
63 | self.logger.info(f'{observer} removed from event observers')
64 | except ValueError:
65 | self.logger.error(f'Failed to remove {observer} from event observers')
66 |
67 | deregister = remove
68 |
69 | def clear(self):
70 | """clears the observers list"""
71 |
72 | self._observers.clear()
73 |
--------------------------------------------------------------------------------
/voicemeeterlib/updater.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import threading
3 | import time
4 |
5 | from .util import comp
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | class Producer(threading.Thread):
11 | """Continously send job queue to the Updater thread at a rate of self._remote.ratelimit."""
12 |
13 | def __init__(self, remote, queue, stop_event):
14 | super().__init__(name='producer', daemon=False)
15 | self._remote = remote
16 | self.queue = queue
17 | self.stop_event = stop_event
18 | self.logger = logger.getChild(self.__class__.__name__)
19 |
20 | def stopped(self):
21 | return self.stop_event.is_set()
22 |
23 | def run(self):
24 | while not self.stopped():
25 | if self._remote.event.pdirty:
26 | self.queue.put('pdirty')
27 | if self._remote.event.mdirty:
28 | self.queue.put('mdirty')
29 | if self._remote.event.midi:
30 | self.queue.put('midi')
31 | if self._remote.event.ldirty:
32 | self.queue.put('ldirty')
33 | time.sleep(self._remote.ratelimit)
34 | self.logger.debug(f'terminating {self.name} thread')
35 | self.queue.put(None)
36 |
37 |
38 | class Updater(threading.Thread):
39 | def __init__(self, remote, queue):
40 | super().__init__(name='updater', daemon=True)
41 | self._remote = remote
42 | self.queue = queue
43 | self._remote._strip_comp = [False] * (self._remote.kind.num_strip_levels)
44 | self._remote._bus_comp = [False] * (self._remote.kind.num_bus_levels)
45 | (
46 | self._remote.cache['strip_level'],
47 | self._remote.cache['bus_level'],
48 | ) = self._remote._get_levels()
49 | self.logger = logger.getChild(self.__class__.__name__)
50 |
51 | def _update_comps(self, strip_level, bus_level):
52 | self._remote._strip_comp, self._remote._bus_comp = (
53 | tuple(not x for x in comp(self._remote.cache['strip_level'], strip_level)),
54 | tuple(not x for x in comp(self._remote.cache['bus_level'], bus_level)),
55 | )
56 |
57 | def run(self):
58 | """
59 | Continously update observers of dirty states.
60 |
61 | Generate _strip_comp, _bus_comp and update level cache if ldirty.
62 | """
63 | while event := self.queue.get():
64 | if event == 'pdirty' and self._remote.pdirty:
65 | self._remote.subject.notify(event)
66 | elif event == 'mdirty' and self._remote.mdirty:
67 | self._remote.subject.notify(event)
68 | elif event == 'midi' and self._remote.get_midi_message():
69 | self._remote.subject.notify(event)
70 | elif event == 'ldirty' and self._remote.ldirty:
71 | self._update_comps(self._remote._strip_buf, self._remote._bus_buf)
72 | self._remote.cache['strip_level'] = self._remote._strip_buf
73 | self._remote.cache['bus_level'] = self._remote._bus_buf
74 | self._remote.subject.notify(event)
75 | self.logger.debug(f'terminating {self.name} thread')
76 |
--------------------------------------------------------------------------------
/voicemeeterlib/util.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import time
3 | from itertools import zip_longest
4 | from typing import Iterator
5 |
6 | from .error import CAPIError, VMError
7 |
8 |
9 | def timeout(func):
10 | """
11 | Times out the login function once time elapsed exceeds remote.timeout.
12 | """
13 |
14 | @functools.wraps(func)
15 | def wrapper(*args, **kwargs):
16 | remote, *_ = args
17 | func(*args, **kwargs)
18 |
19 | err = None
20 | start = time.time()
21 | while time.time() < start + remote.timeout:
22 | try:
23 | time.sleep(0.1) # ensure at least 0.1 delay before clearing dirty
24 | remote.logger.info(
25 | f'{type(remote).__name__}: Successfully logged into {remote} version {remote.version}'
26 | )
27 | remote.logger.debug(f'login time: {round(time.time() - start, 2)}')
28 | err = None
29 | break
30 | except CAPIError as e:
31 | err = e
32 | continue
33 | if err:
34 | raise VMError('Timeout logging into the api')
35 | remote.clear_dirty()
36 |
37 | return wrapper
38 |
39 |
40 | def polling(func):
41 | """
42 | Offers memoization for a set into get operation.
43 |
44 | If sync clear dirty parameters before fetching new value.
45 |
46 | Useful for loop getting if not running callbacks
47 | """
48 |
49 | @functools.wraps(func)
50 | def wrapper(*args, **kwargs):
51 | get = func.__name__ == 'get'
52 | mb_get = func.__name__ == 'get_buttonstatus'
53 | remote, *remaining = args
54 |
55 | if get:
56 | param, *rem = remaining
57 | elif mb_get:
58 | id, mode, *rem = remaining
59 | param = f'mb_{id}_{mode}'
60 |
61 | if param in remote.cache:
62 | return remote.cache.pop(param)
63 | if remote.sync:
64 | remote.clear_dirty()
65 | return func(*args, **kwargs)
66 |
67 | return wrapper
68 |
69 |
70 | def script(func):
71 | """Convert dictionary to script"""
72 |
73 | def wrapper(*args):
74 | remote, script = args
75 | if isinstance(script, dict):
76 | params = ''
77 | for key, val in script.items():
78 | obj, m2, *rem = key.split('-')
79 | index = int(m2) if m2.isnumeric() else int(*rem)
80 | params += ';'.join(
81 | f"{obj}{f'.{m2}stream' if not m2.isnumeric() else ''}[{index}].{k}={int(v) if isinstance(v, bool) else v}"
82 | for k, v in val.items()
83 | )
84 | params += ';'
85 | script = params
86 | return func(remote, script)
87 |
88 | return wrapper
89 |
90 |
91 | def comp(t0: tuple, t1: tuple) -> Iterator[bool]:
92 | """
93 | Generator function, accepts two tuples.
94 |
95 | Evaluates equality of each member in both tuples.
96 | """
97 | for a, b in zip(t0, t1):
98 | yield a == b
99 |
100 |
101 | def grouper(n, iterable, fillvalue=None):
102 | """
103 | Group elements of an iterable by sets of n length
104 | """
105 | args = [iter(iterable)] * n
106 | return zip_longest(fillvalue=fillvalue, *args)
107 |
108 |
109 | def deep_merge(dict1, dict2):
110 | """Generator function for deep merging two dicts"""
111 | for k in set(dict1) | set(dict2):
112 | if k in dict1 and k in dict2:
113 | if isinstance(dict1[k], dict) and isinstance(dict2[k], dict):
114 | yield k, dict(deep_merge(dict1[k], dict2[k]))
115 | else:
116 | yield k, dict2[k]
117 | elif k in dict1:
118 | yield k, dict1[k]
119 | else:
120 | yield k, dict2[k]
121 |
--------------------------------------------------------------------------------
/voicemeeterlib/vban.py:
--------------------------------------------------------------------------------
1 | from abc import abstractmethod
2 |
3 | from . import kinds
4 | from .iremote import IRemote
5 |
6 |
7 | class VbanStream(IRemote):
8 | """
9 | Implements the common interface
10 |
11 | Defines concrete implementation for vban stream
12 | """
13 |
14 | @abstractmethod
15 | def __str__(self):
16 | pass
17 |
18 | @property
19 | def identifier(self) -> str:
20 | return f'vban.{self.direction}stream[{self.index}]'
21 |
22 | @property
23 | def on(self) -> bool:
24 | return self.getter('on') == 1
25 |
26 | @on.setter
27 | def on(self, val: bool):
28 | self.setter('on', 1 if val else 0)
29 |
30 | @property
31 | def name(self) -> str:
32 | return self.getter('name', is_string=True)
33 |
34 | @name.setter
35 | def name(self, val: str):
36 | self.setter('name', val)
37 |
38 | @property
39 | def ip(self) -> str:
40 | return self.getter('ip', is_string=True)
41 |
42 | @ip.setter
43 | def ip(self, val: str):
44 | self.setter('ip', val)
45 |
46 | @property
47 | def port(self) -> int:
48 | return int(self.getter('port'))
49 |
50 | @port.setter
51 | def port(self, val: int):
52 | if not 1024 <= val <= 65535:
53 | self.logger.warning(
54 | f'port got: {val} but expected a value from 1024 to 65535'
55 | )
56 | self.setter('port', val)
57 |
58 | @property
59 | def sr(self) -> int:
60 | return int(self.getter('sr'))
61 |
62 | @sr.setter
63 | def sr(self, val: int):
64 | opts = (11025, 16000, 22050, 24000, 32000, 44100, 48000, 64000, 88200, 96000)
65 | if val not in opts:
66 | self.logger.warning(f'sr got: {val} but expected a value in {opts}')
67 | self.setter('sr', val)
68 |
69 | @property
70 | def channel(self) -> int:
71 | return int(self.getter('channel'))
72 |
73 | @channel.setter
74 | def channel(self, val: int):
75 | if not 1 <= val <= 8:
76 | self.logger.warning(f'channel got: {val} but expected a value from 1 to 8')
77 | self.setter('channel', val)
78 |
79 | @property
80 | def bit(self) -> int:
81 | return 16 if (int(self.getter('bit') == 1)) else 24
82 |
83 | @bit.setter
84 | def bit(self, val: int):
85 | if val not in (16, 24):
86 | self.logger.warning(f'bit got: {val} but expected value 16 or 24')
87 | self.setter('bit', 1 if (val == 16) else 2)
88 |
89 | @property
90 | def quality(self) -> int:
91 | return int(self.getter('quality'))
92 |
93 | @quality.setter
94 | def quality(self, val: int):
95 | if not 0 <= val <= 4:
96 | self.logger.warning(f'quality got: {val} but expected a value from 0 to 4')
97 | self.setter('quality', val)
98 |
99 | @property
100 | def route(self) -> int:
101 | return int(self.getter('route'))
102 |
103 | @route.setter
104 | def route(self, val: int):
105 | if not 0 <= val <= 8:
106 | self.logger.warning(f'route got: {val} but expected a value from 0 to 8')
107 | self.setter('route', val)
108 |
109 |
110 | class VbanInstream(VbanStream):
111 | """
112 | class representing a vban instream
113 |
114 | subclasses VbanStream
115 | """
116 |
117 | def __str__(self):
118 | return f'{type(self).__name__}{self._remote.kind}{self.index}'
119 |
120 | @property
121 | def direction(self) -> str:
122 | return 'in'
123 |
124 | @property
125 | def sr(self) -> int:
126 | return super(VbanInstream, self).sr
127 |
128 | @property
129 | def channel(self) -> int:
130 | return super(VbanInstream, self).channel
131 |
132 | @property
133 | def bit(self) -> int:
134 | return super(VbanInstream, self).bit
135 |
136 |
137 | class VbanAudioInstream(VbanInstream):
138 | """Represents a VBAN Audio Instream"""
139 |
140 |
141 | class VbanMidiInstream(VbanInstream):
142 | """Represents a VBAN Midi Instream"""
143 |
144 |
145 | class VbanTextInstream(VbanInstream):
146 | """Represents a VBAN Text Instream"""
147 |
148 |
149 | class VbanOutstream(VbanStream):
150 | """
151 | class representing a vban outstream
152 |
153 | Subclasses VbanStream
154 | """
155 |
156 | def __str__(self):
157 | return f'{type(self).__name__}{self._remote.kind}{self.index}'
158 |
159 | @property
160 | def direction(self) -> str:
161 | return 'out'
162 |
163 |
164 | class VbanAudioOutstream(VbanOutstream):
165 | """Represents a VBAN Audio Outstream"""
166 |
167 |
168 | class VbanMidiOutstream(VbanOutstream):
169 | """Represents a VBAN Midi Outstream"""
170 |
171 |
172 | def _make_stream_pair(remote, kind):
173 | num_instream, num_outstream, num_midi, num_text = kind.vban
174 |
175 | def _make_cls(i, direction):
176 | match direction:
177 | case 'in':
178 | if i < num_instream:
179 | return VbanAudioInstream(remote, i)
180 | elif i < num_instream + num_midi:
181 | return VbanMidiInstream(remote, i)
182 | else:
183 | return VbanTextInstream(remote, i)
184 | case 'out':
185 | if i < num_outstream:
186 | return VbanAudioOutstream(remote, i)
187 | else:
188 | return VbanMidiOutstream(remote, i)
189 |
190 | return (
191 | tuple(_make_cls(i, 'in') for i in range(num_instream + num_midi + num_text)),
192 | tuple(_make_cls(i, 'out') for i in range(num_outstream + num_midi)),
193 | )
194 |
195 |
196 | def _make_stream_pairs(remote):
197 | return {kind.name: _make_stream_pair(remote, kind) for kind in kinds.all}
198 |
199 |
200 | class Vban:
201 | """
202 | class representing the vban module
203 |
204 | Contains two tuples, one for each stream type
205 | """
206 |
207 | def __init__(self, remote):
208 | self.remote = remote
209 | self.instream, self.outstream = _make_stream_pairs(remote)[remote.kind.name]
210 |
211 | def enable(self):
212 | self.remote.set('vban.Enable', 1)
213 |
214 | def disable(self):
215 | self.remote.set('vban.Enable', 0)
216 |
217 |
218 | def vban_factory(remote) -> Vban:
219 | """
220 | Factory method for vban
221 |
222 | Returns a class that represents the VBAN module.
223 | """
224 | VBAN_cls = Vban
225 | return type(f'{VBAN_cls.__name__}', (VBAN_cls,), {})(remote)
226 |
227 |
228 | def request_vban_obj(remote) -> Vban:
229 | """
230 | Vban entry point.
231 |
232 | Returns a reference to a Vban class of a kind
233 | """
234 | return vban_factory(remote)
235 |
--------------------------------------------------------------------------------