14 Commits

Author SHA1 Message Date
8c14de73cc Merge branch 'dev' of ssh://github.com/onyx-and-iris/voicemeeter-api-python into dev 2026-04-28 10:38:28 +01:00
53ba6af494 Merge pull request #21 from onyx-and-iris/dependabot/pip/pytest-9.0.3
Bump pytest from 8.3.4 to 9.0.3
2026-04-21 11:43:26 +01:00
dependabot[bot]
01ed309969 Bump pytest from 8.3.4 to 9.0.3
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.4 to 9.0.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.4...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 10:42:02 +00:00
9d19cf7076 remove successful add/remove observer logs, this is normal program flow.
otherwise log at debug level
2026-03-20 03:30:15 +00:00
821371ee27 remove builder progress logs, they aren't very useful and we test the factories anyway. 2026-03-20 03:28:20 +00:00
6eaa799c20 the platform check is mostly redundant because import winreg will have already failed on most python installations
swith to ct.WinDLL which is intended for C APIs using stdcall convention.
2026-03-20 03:27:24 +00:00
48614ab5fa Merge pull request #20 from onyx-and-iris/dependabot/pip/virtualenv-20.36.1
Bump virtualenv from 20.28.1 to 20.36.1
2026-03-18 06:20:31 +00:00
dependabot[bot]
99350f2c63 Bump virtualenv from 20.28.1 to 20.36.1
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 20.28.1 to 20.36.1.
- [Release notes](https://github.com/pypa/virtualenv/releases)
- [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/virtualenv/compare/20.28.1...20.36.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 06:20:07 +00:00
2a7a1c5d2a Merge pull request #19 from onyx-and-iris/dependabot/pip/filelock-3.20.3
Bump filelock from 3.16.1 to 3.20.3
2026-03-18 06:18:47 +00:00
dependabot[bot]
f0664f9cfb Bump filelock from 3.16.1 to 3.20.3
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.16.1 to 3.20.3.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.16.1...3.20.3)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 3.20.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 06:18:12 +00:00
e395e7a373 upd tested against 2026-03-15 22:02:31 +00:00
842feb2407 remote is now our ABC - as it should be because it is the launching point of the interface.
it no longer inherits from CBindings.

move steps abstract method into Remote class. This is a much more meaningful abstraction - because it is the principle behaviour that distinguishes each kind of Remote.

add wrapper methods to CBindings. This provides a cleaner api for the Remote class.

import abc as namespace throughout the package.
2026-03-15 22:02:17 +00:00
84b4426e44 add poetry hooks to pre-commit config 2026-03-15 16:27:15 +00:00
0396892530 fix url 2026-03-07 21:34:26 +00:00
15 changed files with 233 additions and 150 deletions

View File

@@ -38,7 +38,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: environment:
name: pypi name: pypi
url: https://pypi.org/project/vban-cmd/ url: https://pypi.org/project/voicemeeter-api/
permissions: permissions:
id-token: write id-token: write
steps: steps:

View File

@@ -5,3 +5,9 @@ repos:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: '2.3.2'
hooks:
- id: poetry-check
- id: poetry-lock

View File

@@ -14,9 +14,9 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Tested against ## Tested against
- Basic 1.1.1.1 - Basic 1.1.2.2
- Banana 2.1.1.1 - Banana 2.1.2.2
- Potato 3.1.1.1 - Potato 3.1.2.2
## Requirements ## Requirements

78
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]] [[package]]
name = "cachetools" name = "cachetools"
@@ -55,7 +55,7 @@ description = "Backport of PEP 654 (exception groups)"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"] groups = ["dev"]
markers = "python_version < \"3.11\"" markers = "python_version == \"3.10\""
files = [ files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
@@ -66,21 +66,16 @@ test = ["pytest (>=6)"]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.16.1" version = "3.20.3"
description = "A platform independent file lock." description = "A platform independent file lock."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
] ]
[package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.0.0" version = "2.0.0"
@@ -150,6 +145,21 @@ files = [
{file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"}, {file = "pyenv_inspect-0.4.0-py3-none-any.whl", hash = "sha256:618683ae7d3e6db14778d58aa0fc6b3170180d944669b5d35a8aa4fb7db550d2"},
] ]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]] [[package]]
name = "pyproject-api" name = "pyproject-api"
version = "1.8.0" version = "1.8.0"
@@ -172,26 +182,27 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytes
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.4" version = "9.0.3"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.10"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
] ]
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = "*" iniconfig = ">=1.0.1"
packaging = "*" packaging = ">=22"
pluggy = ">=1.5,<2" pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""} tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "pytest-randomly" name = "pytest-randomly"
@@ -243,7 +254,7 @@ description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"] groups = ["main", "dev"]
markers = "python_version < \"3.11\"" markers = "python_version == \"3.10\""
files = [ files = [
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
{file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
@@ -309,37 +320,38 @@ test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.9"
groups = ["dev"] groups = ["dev"]
markers = "python_version < \"3.11\"" markers = "python_version == \"3.10\""
files = [ files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.28.1" version = "20.36.1"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"}, {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"}, {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
] ]
[package.dependencies] [package.dependencies]
distlib = ">=0.3.7,<1" distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4" filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5" platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 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) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
[[package]] [[package]]
name = "virtualenv-pyenv" name = "virtualenv-pyenv"
@@ -360,4 +372,4 @@ virtualenv = "*"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10" python-versions = ">=3.10"
content-hash = "6339967c3f6cad8e4db7047ef3d12a5b059a279d0f7c98515c961477680bab8f" content-hash = "0eb05efe6d583f24454e3a0d3b2b3cc8b0a9c5a7a816ffa1471eb9e2655e2246"

View File

@@ -15,7 +15,7 @@ packages = [{ include = "voicemeeterlib" }]
poethepoet = ">=0.42.0" poethepoet = ">=0.42.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3.4" pytest = "^9.0.3"
pytest-randomly = "^3.16.0" pytest-randomly = "^3.16.0"
ruff = "^0.8.6" ruff = "^0.8.6"
tox = "^4.23.2" tox = "^4.23.2"

View File

@@ -1,5 +1,5 @@
import abc
import time import time
from abc import abstractmethod
from enum import IntEnum from enum import IntEnum
from math import log from math import log
from typing import Union from typing import Union
@@ -22,7 +22,7 @@ class Bus(IRemote):
Defines concrete implementation for bus Defines concrete implementation for bus
""" """
@abstractmethod @abc.abstractmethod
def __str__(self): def __str__(self):
pass pass

View File

@@ -1,6 +1,5 @@
import ctypes as ct import ctypes as ct
import logging import logging
from abc import ABCMeta
from ctypes.wintypes import CHAR, FLOAT, LONG, WCHAR from ctypes.wintypes import CHAR, FLOAT, LONG, WCHAR
from .error import CAPIError from .error import CAPIError
@@ -9,11 +8,10 @@ from .inst import libc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CBindings(metaclass=ABCMeta): class CBindings:
""" """Class responsible for defining C function bindings.
C bindings defined here.
Maps expected ctype argument and res types for each binding. Wrapper methods are provided for each C function to handle error checking and logging.
""" """
logger_cbindings = logger.getChild('CBindings') logger_cbindings = logger.getChild('CBindings')
@@ -111,7 +109,8 @@ class CBindings(metaclass=ABCMeta):
bind_get_midi_message.restype = LONG bind_get_midi_message.restype = LONG
bind_get_midi_message.argtypes = [ct.POINTER(CHAR * 1024), LONG] bind_get_midi_message.argtypes = [ct.POINTER(CHAR * 1024), LONG]
def call(self, func, *args, ok=(0,), ok_exp=None): def _call(self, func, *args, ok=(0,), ok_exp=None):
"""Call a C function and handle errors."""
try: try:
res = func(*args) res = func(*args)
if ok_exp is None: if ok_exp is None:
@@ -123,3 +122,93 @@ class CBindings(metaclass=ABCMeta):
except CAPIError as e: except CAPIError as e:
self.logger_cbindings.exception(f'{type(e).__name__}: {e}') self.logger_cbindings.exception(f'{type(e).__name__}: {e}')
raise raise
def login(self, **kwargs):
"""Login to Voicemeeter API"""
return self._call(self.bind_login, **kwargs)
def logout(self):
"""Logout from Voicemeeter API"""
return self._call(self.bind_logout)
def run_voicemeeter(self, value):
"""Run Voicemeeter with specified type"""
return self._call(self.bind_run_voicemeeter, value)
def get_voicemeeter_type(self, type_ref):
"""Get Voicemeeter type"""
return self._call(self.bind_get_voicemeeter_type, type_ref)
def get_voicemeeter_version(self, version_ref):
"""Get Voicemeeter version"""
return self._call(self.bind_get_voicemeeter_version, version_ref)
def is_parameters_dirty(self, **kwargs):
"""Check if parameters are dirty"""
return self._call(self.bind_is_parameters_dirty, **kwargs)
def macro_button_is_dirty(self, **kwargs):
"""Check if macro button parameters are dirty"""
if hasattr(self, 'bind_macro_button_is_dirty'):
return self._call(self.bind_macro_button_is_dirty, **kwargs)
raise AttributeError('macro_button_is_dirty not available')
def get_parameter_float(self, param_name, value_ref):
"""Get float parameter value"""
return self._call(self.bind_get_parameter_float, param_name, value_ref)
def set_parameter_float(self, param_name, value):
"""Set float parameter value"""
return self._call(self.bind_set_parameter_float, param_name, value)
def get_parameter_string_w(self, param_name, buffer_ref):
"""Get string parameter value (Unicode)"""
return self._call(self.bind_get_parameter_string_w, param_name, buffer_ref)
def set_parameter_string_w(self, param_name, value):
"""Set string parameter value (Unicode)"""
return self._call(self.bind_set_parameter_string_w, param_name, value)
def macro_button_get_status(self, id_, state_ref, mode):
"""Get macro button status"""
if hasattr(self, 'bind_macro_button_get_status'):
return self._call(self.bind_macro_button_get_status, id_, state_ref, mode)
raise AttributeError('macro_button_get_status not available')
def macro_button_set_status(self, id_, state, mode):
"""Set macro button status"""
if hasattr(self, 'bind_macro_button_set_status'):
return self._call(self.bind_macro_button_set_status, id_, state, mode)
raise AttributeError('macro_button_set_status not available')
def get_level(self, type_, index, value_ref):
"""Get audio level"""
return self._call(self.bind_get_level, type_, index, value_ref)
def input_get_device_number(self, **kwargs):
"""Get number of input devices"""
return self._call(self.bind_input_get_device_number, **kwargs)
def output_get_device_number(self, **kwargs):
"""Get number of output devices"""
return self._call(self.bind_output_get_device_number, **kwargs)
def input_get_device_desc_w(self, index, type_ref, name_ref, hwid_ref):
"""Get input device description"""
return self._call(
self.bind_input_get_device_desc_w, index, type_ref, name_ref, hwid_ref
)
def output_get_device_desc_w(self, index, type_ref, name_ref, hwid_ref):
"""Get output device description"""
return self._call(
self.bind_output_get_device_desc_w, index, type_ref, name_ref, hwid_ref
)
def get_midi_message(self, buffer_ref, length, **kwargs):
"""Get MIDI message"""
return self._call(self.bind_get_midi_message, buffer_ref, length, **kwargs)
def set_parameters(self, script):
"""Set multiple parameters via script"""
return self._call(self.bind_set_parameters, script)

View File

@@ -1,4 +1,4 @@
from abc import abstractmethod import abc
from typing import Union from typing import Union
from .iremote import IRemote from .iremote import IRemote
@@ -7,19 +7,19 @@ from .iremote import IRemote
class Adapter(IRemote): class Adapter(IRemote):
"""Adapter to the common interface.""" """Adapter to the common interface."""
@abstractmethod @abc.abstractmethod
def ins(self): def ins(self):
pass pass
@abstractmethod @abc.abstractmethod
def outs(self): def outs(self):
pass pass
@abstractmethod @abc.abstractmethod
def input(self): def input(self):
pass pass
@abstractmethod @abc.abstractmethod
def output(self): def output(self):
pass pass

View File

@@ -1,6 +1,4 @@
import logging import logging
from abc import abstractmethod
from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import Iterable from typing import Iterable
@@ -10,7 +8,7 @@ from .command import Command
from .config import request_config as configs from .config import request_config as configs
from .device import Device from .device import Device
from .error import VMError from .error import VMError
from .kinds import KindMapClass from .kinds import KindId, KindMapClass
from .kinds import request_kind_map as kindmap from .kinds import request_kind_map as kindmap
from .macrobutton import MacroButton from .macrobutton import MacroButton
from .recorder import Recorder from .recorder import Recorder
@@ -28,34 +26,11 @@ class FactoryBuilder:
Separates construction from representation. Separates construction from representation.
""" """
BuilderProgress = IntEnum(
'BuilderProgress',
'strip bus command macrobutton vban device option recorder patch fx',
start=0,
)
def __init__(self, factory, kind: KindMapClass): def __init__(self, factory, kind: KindMapClass):
self._factory = factory self._factory = factory
self.kind = kind self.kind = kind
self._info = (
f'Finished building strips for {self._factory}',
f'Finished building buses for {self._factory}',
f'Finished building commands for {self._factory}',
f'Finished building macrobuttons for {self._factory}',
f'Finished building vban in/out streams for {self._factory}',
f'Finished building device for {self._factory}',
f'Finished building option for {self._factory}',
f'Finished building recorder for {self._factory}',
f'Finished building patch for {self._factory}',
f'Finished building fx for {self._factory}',
)
self.logger = logger.getChild(self.__class__.__name__) self.logger = logger.getChild(self.__class__.__name__)
def _pinfo(self, name: str) -> None:
"""prints progress status for each step"""
name = name.split('_')[1]
self.logger.debug(self._info[int(getattr(self.BuilderProgress, name))])
def make_strip(self): def make_strip(self):
self._factory.strip = tuple( self._factory.strip = tuple(
strip(i < self.kind.phys_in, self._factory, i) strip(i < self.kind.phys_in, self._factory, i)
@@ -137,11 +112,6 @@ class FactoryBase(Remote):
def __str__(self) -> str: def __str__(self) -> str:
return f'Voicemeeter {self.kind}' return f'Voicemeeter {self.kind}'
@property
@abstractmethod
def steps(self):
pass
@cached_property @cached_property
def configs(self): def configs(self):
self._configs = configs(self.kind.name) self._configs = configs(self.kind.name)
@@ -157,12 +127,14 @@ class BasicFactory(FactoryBase):
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls is BasicFactory: if cls is BasicFactory:
raise TypeError(f"'{cls.__name__}' does not support direct instantiation") ERR_MSG = f"'{cls.__name__}' does not support direct instantiation"
raise TypeError(ERR_MSG)
return object.__new__(cls) return object.__new__(cls)
def __init__(self, kind_id, **kwargs): def __init__(self, kind_id, **kwargs):
super().__init__(kind_id, **kwargs) super().__init__(kind_id, **kwargs)
[step()._pinfo(step.__name__) for step in self.steps] for step in self.steps:
step()
@property @property
def steps(self) -> Iterable: def steps(self) -> Iterable:
@@ -179,12 +151,14 @@ class BananaFactory(FactoryBase):
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls is BananaFactory: if cls is BananaFactory:
raise TypeError(f"'{cls.__name__}' does not support direct instantiation") ERR_MSG = f"'{cls.__name__}' does not support direct instantiation"
raise TypeError(ERR_MSG)
return object.__new__(cls) return object.__new__(cls)
def __init__(self, kind_id, **kwargs): def __init__(self, kind_id, **kwargs):
super().__init__(kind_id, **kwargs) super().__init__(kind_id, **kwargs)
[step()._pinfo(step.__name__) for step in self.steps] for step in self.steps:
step()
@property @property
def steps(self) -> Iterable: def steps(self) -> Iterable:
@@ -201,12 +175,14 @@ class PotatoFactory(FactoryBase):
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls is PotatoFactory: if cls is PotatoFactory:
raise TypeError(f"'{cls.__name__}' does not support direct instantiation") ERR_MSG = f"'{cls.__name__}' does not support direct instantiation"
raise TypeError(ERR_MSG)
return object.__new__(cls) return object.__new__(cls)
def __init__(self, kind_id: str, **kwargs): def __init__(self, kind_id: str, **kwargs):
super().__init__(kind_id, **kwargs) super().__init__(kind_id, **kwargs)
[step()._pinfo(step.__name__) for step in self.steps] for step in self.steps:
step()
@property @property
def steps(self) -> Iterable: def steps(self) -> Iterable:
@@ -232,7 +208,8 @@ def remote_factory(kind_id: str, **kwargs) -> Remote:
case 'potato': case 'potato':
_factory = PotatoFactory _factory = PotatoFactory
case _: case _:
raise ValueError(f"Unknown Voicemeeter kind '{kind_id}'") ERR_MSG = f'Unknown Voicemeeter kind {kind_id}, expected one of {[k.name.lower() for k in KindId]}'
raise ValueError(ERR_MSG)
return type(f'Remote{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs) return type(f'Remote{kind_id.capitalize()}', (_factory,), {})(kind_id, **kwargs)
@@ -249,6 +226,6 @@ def request_remote_obj(kind_id: str, **kwargs) -> Remote:
try: try:
REMOTE_obj = remote_factory(kind_id, **kwargs) REMOTE_obj = remote_factory(kind_id, **kwargs)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
logger_entry.exception(f'{type(e).__name__}: {e}') logger_entry.error(f'{type(e).__name__}: {e}')
raise VMError(str(e)) from e raise VMError(str(e)) from e
return REMOTE_obj return REMOTE_obj

View File

@@ -1,15 +1,22 @@
import ctypes as ct import ctypes as ct
import platform import platform
import winreg
from pathlib import Path from pathlib import Path
from .error import InstallError from .error import InstallError, VMError
try:
import winreg
except ImportError as e:
ERR_MSG = 'winreg module not found, only Windows OS supported'
raise VMError(ERR_MSG) from e
# Defense against edge cases where winreg imports but we're not on Windows
if platform.system() != 'Windows':
ERR_MSG = f'Unsupported OS: {platform.system()}, only Windows OS supported'
raise VMError(ERR_MSG)
BITS = 64 if ct.sizeof(ct.c_void_p) == 8 else 32 BITS = 64 if ct.sizeof(ct.c_void_p) == 8 else 32
if platform.system() != 'Windows':
raise InstallError('Only Windows OS supported')
VM_KEY = 'VB:Voicemeeter {17359A74-1236-5467}' VM_KEY = 'VB:Voicemeeter {17359A74-1236-5467}'
REG_KEY = '\\'.join( REG_KEY = '\\'.join(
@@ -37,12 +44,14 @@ def get_vmpath():
try: try:
vm_parent = Path(get_vmpath()).parent vm_parent = Path(get_vmpath()).parent
except FileNotFoundError as e: except FileNotFoundError as e:
raise InstallError('Unable to fetch DLL path from the registry') from e ERR_MSG = 'Unable to fetch DLL path from the registry'
raise InstallError(ERR_MSG) from e
DLL_NAME = f'VoicemeeterRemote{"64" if BITS == 64 else ""}.dll' DLL_NAME = f'VoicemeeterRemote{"64" if BITS == 64 else ""}.dll'
dll_path = vm_parent.joinpath(DLL_NAME) dll_path = vm_parent.joinpath(DLL_NAME)
if not dll_path.is_file(): if not dll_path.is_file():
raise InstallError(f'Could not find {dll_path}') ERR_MSG = f'Could not find {dll_path}'
raise InstallError(ERR_MSG)
libc = ct.CDLL(str(dll_path)) libc = ct.WinDLL(str(dll_path))

View File

@@ -1,11 +1,11 @@
import abc
import logging import logging
import time import time
from abc import ABCMeta, abstractmethod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class IRemote(metaclass=ABCMeta): class IRemote(abc.ABC):
""" """
Common interface between base class and extended (higher) classes Common interface between base class and extended (higher) classes
@@ -33,7 +33,7 @@ class IRemote(metaclass=ABCMeta):
cmd += (f'.{param}',) cmd += (f'.{param}',)
return ''.join(cmd) return ''.join(cmd)
@abstractmethod @abc.abstractmethod
def identifier(self): def identifier(self):
pass pass

View File

@@ -1,8 +1,8 @@
import abc
import ctypes as ct import ctypes as ct
import logging import logging
import threading import threading
import time import time
from abc import abstractmethod
from queue import Queue from queue import Queue
from typing import Iterable, Optional, Union from typing import Iterable, Optional, Union
@@ -19,12 +19,13 @@ from .util import deep_merge, grouper, polling, script, timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Remote(CBindings): class Remote(abc.ABC):
"""Base class responsible for wrapping the C Remote API""" """An abstract base class for Voicemeeter Remote API wrappers. Defines common methods and properties."""
DELAY = 0.001 DELAY = 0.001
def __init__(self, **kwargs): def __init__(self, **kwargs):
self._bindings = CBindings()
self.strip_mode = 0 self.strip_mode = 0
self.cache = {} self.cache = {}
self.midi = Midi() self.midi = Midi()
@@ -52,10 +53,10 @@ class Remote(CBindings):
self.init_thread() self.init_thread()
return self return self
@abstractmethod @property
def __str__(self): @abc.abstractmethod
"""Ensure subclasses override str magic method""" def steps(self):
pass """Steps required to build the interface for this Voicemeeter kind"""
def init_thread(self): def init_thread(self):
"""Starts updates thread.""" """Starts updates thread."""
@@ -76,7 +77,7 @@ class Remote(CBindings):
@timeout @timeout
def login(self) -> None: def login(self) -> None:
"""Login to the API, initialize dirty parameters""" """Login to the API, initialize dirty parameters"""
self.gui.launched = self.call(self.bind_login, ok=(0, 1)) == 0 self.gui.launched = self._bindings.login(ok=(0, 1)) == 0
if not self.gui.launched: if not self.gui.launched:
self.logger.info( self.logger.info(
'Voicemeeter engine running but GUI not launched. Launching the GUI now.' 'Voicemeeter engine running but GUI not launched. Launching the GUI now.'
@@ -89,20 +90,20 @@ class Remote(CBindings):
value = KindId[kind_id.upper()].value value = KindId[kind_id.upper()].value
if BITS == 64 and self.bits == 64: if BITS == 64 and self.bits == 64:
value += 3 value += 3
self.call(self.bind_run_voicemeeter, value) self._bindings.run_voicemeeter(value)
@property @property
def type(self) -> str: def type(self) -> str:
"""Returns the type of Voicemeeter installation (basic, banana, potato).""" """Returns the type of Voicemeeter installation (basic, banana, potato)."""
type_ = ct.c_long() type_ = ct.c_long()
self.call(self.bind_get_voicemeeter_type, ct.byref(type_)) self._bindings.get_voicemeeter_type(ct.byref(type_))
return KindId(type_.value).name.lower() return KindId(type_.value).name.lower()
@property @property
def version(self) -> str: def version(self) -> str:
"""Returns Voicemeeter's version as a string""" """Returns Voicemeeter's version as a string"""
ver = ct.c_long() ver = ct.c_long()
self.call(self.bind_get_voicemeeter_version, ct.byref(ver)) self._bindings.get_voicemeeter_version(ct.byref(ver))
return '{}.{}.{}.{}'.format( return '{}.{}.{}.{}'.format(
(ver.value & 0xFF000000) >> 24, (ver.value & 0xFF000000) >> 24,
(ver.value & 0x00FF0000) >> 16, (ver.value & 0x00FF0000) >> 16,
@@ -113,13 +114,13 @@ class Remote(CBindings):
@property @property
def pdirty(self) -> bool: def pdirty(self) -> bool:
"""True iff UI parameters have been updated.""" """True iff UI parameters have been updated."""
return self.call(self.bind_is_parameters_dirty, ok=(0, 1)) == 1 return self._bindings.is_parameters_dirty(ok=(0, 1)) == 1
@property @property
def mdirty(self) -> bool: def mdirty(self) -> bool:
"""True iff MB parameters have been updated.""" """True iff MB parameters have been updated."""
try: try:
return self.call(self.bind_macro_button_is_dirty, ok=(0, 1)) == 1 return self._bindings.macro_button_is_dirty(ok=(0, 1)) == 1
except AttributeError as e: except AttributeError as e:
self.logger.exception(f'{type(e).__name__}: {e}') self.logger.exception(f'{type(e).__name__}: {e}')
raise CAPIError('VBVMR_MacroButton_IsDirty', -9) from e raise CAPIError('VBVMR_MacroButton_IsDirty', -9) from e
@@ -149,10 +150,10 @@ class Remote(CBindings):
"""Gets a string or float parameter""" """Gets a string or float parameter"""
if is_string: if is_string:
buf = ct.create_unicode_buffer(512) buf = ct.create_unicode_buffer(512)
self.call(self.bind_get_parameter_string_w, param.encode(), ct.byref(buf)) self._bindings.get_parameter_string_w(param.encode(), ct.byref(buf))
else: else:
buf = ct.c_float() buf = ct.c_float()
self.call(self.bind_get_parameter_float, param.encode(), ct.byref(buf)) self._bindings.get_parameter_float(param.encode(), ct.byref(buf))
return buf.value return buf.value
def set(self, param: str, val: Union[str, float]) -> None: def set(self, param: str, val: Union[str, float]) -> None:
@@ -160,12 +161,11 @@ class Remote(CBindings):
if isinstance(val, str): if isinstance(val, str):
if len(val) >= 512: if len(val) >= 512:
raise VMError('String is too long') raise VMError('String is too long')
self.call( self._bindings.set_parameter_string_w(param.encode(), ct.c_wchar_p(val))
self.bind_set_parameter_string_w, param.encode(), ct.c_wchar_p(val)
)
else: else:
self.call( self._bindings.set_parameter_float(
self.bind_set_parameter_float, param.encode(), ct.c_float(float(val)) param.encode(),
ct.c_float(float(val)),
) )
self.cache[param] = val self.cache[param] = val
@@ -174,8 +174,7 @@ class Remote(CBindings):
"""Gets a macrobutton parameter""" """Gets a macrobutton parameter"""
c_state = ct.c_float() c_state = ct.c_float()
try: try:
self.call( self._bindings.macro_button_get_status(
self.bind_macro_button_get_status,
ct.c_long(id_), ct.c_long(id_),
ct.byref(c_state), ct.byref(c_state),
ct.c_long(mode), ct.c_long(mode),
@@ -189,8 +188,7 @@ class Remote(CBindings):
"""Sets a macrobutton parameter. Caches value""" """Sets a macrobutton parameter. Caches value"""
c_state = ct.c_float(float(val)) c_state = ct.c_float(float(val))
try: try:
self.call( self._bindings.macro_button_set_status(
self.bind_macro_button_set_status,
ct.c_long(id_), ct.c_long(id_),
c_state, c_state,
ct.c_long(mode), ct.c_long(mode),
@@ -204,8 +202,8 @@ class Remote(CBindings):
"""Retrieves number of physical devices connected""" """Retrieves number of physical devices connected"""
if direction not in ('in', 'out'): if direction not in ('in', 'out'):
raise VMError('Expected a direction: in or out') raise VMError('Expected a direction: in or out')
func = getattr(self, f'bind_{direction}put_get_device_number') func = getattr(self._bindings, f'{direction}put_get_device_number')
res = self.call(func, ok_exp=lambda r: r >= 0) res = func(ok_exp=lambda r: r >= 0)
return res return res
def get_device_description(self, index: int, direction: str = None) -> tuple: def get_device_description(self, index: int, direction: str = None) -> tuple:
@@ -215,9 +213,8 @@ class Remote(CBindings):
type_ = ct.c_long() type_ = ct.c_long()
name = ct.create_unicode_buffer(256) name = ct.create_unicode_buffer(256)
hwid = ct.create_unicode_buffer(256) hwid = ct.create_unicode_buffer(256)
func = getattr(self, f'bind_{direction}put_get_device_desc_w') func = getattr(self._bindings, f'{direction}put_get_device_desc_w')
self.call( func(
func,
ct.c_long(index), ct.c_long(index),
ct.byref(type_), ct.byref(type_),
ct.byref(name), ct.byref(name),
@@ -228,9 +225,7 @@ class Remote(CBindings):
def get_level(self, type_: int, index: int) -> float: def get_level(self, type_: int, index: int) -> float:
"""Retrieves a single level value""" """Retrieves a single level value"""
val = ct.c_float() val = ct.c_float()
self.call( self._bindings.get_level(ct.c_long(type_), ct.c_long(index), ct.byref(val))
self.bind_get_level, ct.c_long(type_), ct.c_long(index), ct.byref(val)
)
return val.value return val.value
def _get_levels(self) -> Iterable: def _get_levels(self) -> Iterable:
@@ -248,8 +243,7 @@ class Remote(CBindings):
def get_midi_message(self): def get_midi_message(self):
n = ct.c_long(1024) n = ct.c_long(1024)
buf = ct.create_string_buffer(1024) buf = ct.create_string_buffer(1024)
res = self.call( res = self._bindings.get_midi_message(
self.bind_get_midi_message,
ct.byref(buf), ct.byref(buf),
n, n,
ok=(-5, -6), # no data received from midi device ok=(-5, -6), # no data received from midi device
@@ -272,7 +266,7 @@ class Remote(CBindings):
"""Sets many parameters from a script""" """Sets many parameters from a script"""
if len(script) > 48000: if len(script) > 48000:
raise ValueError('Script too large, max size 48kB') raise ValueError('Script too large, max size 48kB')
self.call(self.bind_set_parameters, script.encode()) self._bindings.set_parameters(script.encode())
time.sleep(self.DELAY * 5) time.sleep(self.DELAY * 5)
def apply(self, data: dict): def apply(self, data: dict):
@@ -339,7 +333,7 @@ class Remote(CBindings):
def logout(self) -> None: def logout(self) -> None:
"""Logout of the API""" """Logout of the API"""
time.sleep(0.1) time.sleep(0.1)
self.call(self.bind_logout) self._bindings.logout()
self.logger.info(f'{type(self).__name__}: Successfully logged out of {self}') self.logger.info(f'{type(self).__name__}: Successfully logged out of {self}')
def __exit__(self, exc_type, exc_value, exc_traceback) -> None: def __exit__(self, exc_type, exc_value, exc_traceback) -> None:

View File

@@ -1,5 +1,5 @@
import abc
import time import time
from abc import abstractmethod
from math import log from math import log
from typing import Union from typing import Union
@@ -15,7 +15,7 @@ class Strip(IRemote):
Defines concrete implementation for strip Defines concrete implementation for strip
""" """
@abstractmethod @abc.abstractmethod
def __str__(self): def __str__(self):
pass pass

View File

@@ -7,7 +7,7 @@ class Subject:
def __init__(self): def __init__(self):
"""Adds support for observers and callbacks""" """Adds support for observers and callbacks"""
self._observers = list() self._observers = []
self.logger = logger.getChild(self.__class__.__name__) self.logger = logger.getChild(self.__class__.__name__)
@property @property
@@ -34,15 +34,13 @@ class Subject:
for o in iterator: for o in iterator:
if o not in self._observers: if o not in self._observers:
self._observers.append(o) self._observers.append(o)
self.logger.info(f'{o} added to event observers')
else: else:
self.logger.error(f'Failed to add {o} to event observers') self.logger.debug(f'Observer {o} already in observers list')
except TypeError: except TypeError:
if observer not in self._observers: if observer not in self._observers:
self._observers.append(observer) self._observers.append(observer)
self.logger.info(f'{observer} added to event observers')
else: else:
self.logger.error(f'Failed to add {observer} to event observers') self.logger.debug(f'Observer {observer} already in observers list')
register = add register = add
@@ -54,15 +52,13 @@ class Subject:
for o in iterator: for o in iterator:
try: try:
self._observers.remove(o) self._observers.remove(o)
self.logger.info(f'{o} removed from event observers')
except ValueError: except ValueError:
self.logger.error(f'Failed to remove {o} from event observers') self.logger.debug(f'Observer {o} not found in observers list')
except TypeError: except TypeError:
try: try:
self._observers.remove(observer) self._observers.remove(observer)
self.logger.info(f'{observer} removed from event observers')
except ValueError: except ValueError:
self.logger.error(f'Failed to remove {observer} from event observers') self.logger.debug(f'Observer {observer} not found in observers list')
deregister = remove deregister = remove

View File

@@ -1,4 +1,4 @@
from abc import abstractmethod import abc
from . import kinds from . import kinds
from .iremote import IRemote from .iremote import IRemote
@@ -11,7 +11,7 @@ class VbanStream(IRemote):
Defines concrete implementation for vban stream Defines concrete implementation for vban stream
""" """
@abstractmethod @abc.abstractmethod
def __str__(self): def __str__(self):
pass pass