├── .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 | tests: 158tests158 2 | -------------------------------------------------------------------------------- /tests/reports/badge-basic.svg: -------------------------------------------------------------------------------- 1 | tests: 115tests115 2 | -------------------------------------------------------------------------------- /tests/reports/badge-potato.svg: -------------------------------------------------------------------------------- 1 | tests: 183tests183 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 | --------------------------------------------------------------------------------