diff --git a/.gitignore b/.gitignore index 68bc17f..810e605 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,8 @@ ipython_config.py # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml +.pdm-python +.pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -158,3 +160,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +controllerClient/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2fbfcb6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "black-formatter.args": [ + "--line-length=120" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed66ce5 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# example-package diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..be73748 --- /dev/null +++ b/__main__.py @@ -0,0 +1,9 @@ +import voicemeeterlib + +import nvda_voicemeeter + +kind_id = "potato" + +with voicemeeterlib.api("potato") as vm: + with nvda_voicemeeter.build(f"Voicemeeter {kind_id.capitalize()} NVDA", vm) as window: + window.run() diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..e07e471 --- /dev/null +++ b/pdm.lock @@ -0,0 +1,51 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +cross_platform = true +static_urls = false +lock_version = "4.3" +content_hash = "sha256:0dfd1ea07c294dd2b837a34ff9d286134e5119c6249e4f9cd6c1ed121de97851" + +[[package]] +name = "pyparsing" +version = "3.1.1" +requires_python = ">=3.6.8" +summary = "pyparsing module - Classes and methods to define and execute parsing grammars" +files = [ + {file = "pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb"}, + {file = "pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db"}, +] + +[[package]] +name = "pysimplegui" +version = "4.60.5" +summary = "Python GUIs for Humans. Launched in 2018. It's 2022 & PySimpleGUI is an ACTIVE & supported project. Super-simple to create custom GUI's. 325+ Demo programs & Cookbook for rapid start. Extensive documentation. Main docs at www.PySimpleGUI.org. Fun & your success are the focus. Examples using Machine Learning (GUI, OpenCV Integration), Rainmeter Style Desktop Widgets, Matplotlib + Pyplot, PIL support, add GUI to command line scripts, PDF & Image Viewers. Great for beginners & advanced GUI programmers." +files = [ + {file = "PySimpleGUI-4.60.5-py3-none-any.whl", hash = "sha256:004f33311ee685a5287fad54f500f7290b40d7c806044e478b1384f85dedce64"}, + {file = "PySimpleGUI-4.60.5.tar.gz", hash = "sha256:31014d1cc5eef1373d7e93564ff2604662645cc774a939b1f01aa253e7f9d78b"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +requires_python = ">=3.7" +summary = "A lil' TOML parser" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "voicemeeter-api" +version = "2.4.8" +requires_python = ">=3.10,<4.0" +summary = "A Python wrapper for the Voiceemeter API" +dependencies = [ + "tomli<3.0.0,>=2.0.1; python_version < \"3.11\"", +] +files = [ + {file = "voicemeeter_api-2.4.8-py3-none-any.whl", hash = "sha256:a3ff9e6f7516d2adedde93f6976526b385d8ae6d86598bfe541a44f498f42ea6"}, + {file = "voicemeeter_api-2.4.8.tar.gz", hash = "sha256:0d37a9f2af0f68087aa9c76a8cfb2ba44c6b75bb344e8dfa9fd18ad2c862730c"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a45dc0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "nvda_voicemeeter" +version = "0.1.0" +description = "A Voicemeeter app compatible with NVDA" +authors = [ + {name = "onyx-and-iris", email = "code@onyxandiris.online"}, +] +dependencies = [ + "pysimplegui>=4.60.5", + "pyparsing>=3.1.1", + "voicemeeter-api>=2.4.8", +] +requires-python = ">=3.10" +readme = "README.md" +license = {text = "MIT"} + diff --git a/src/nvda_voicemeeter/__init__.py b/src/nvda_voicemeeter/__init__.py new file mode 100644 index 0000000..9f2b740 --- /dev/null +++ b/src/nvda_voicemeeter/__init__.py @@ -0,0 +1 @@ +from .window import request_window_object as build diff --git a/src/nvda_voicemeeter/cdll.py b/src/nvda_voicemeeter/cdll.py new file mode 100644 index 0000000..0c9b37a --- /dev/null +++ b/src/nvda_voicemeeter/cdll.py @@ -0,0 +1,10 @@ +import ctypes as ct +from pathlib import Path + +bits = 64 if ct.sizeof(ct.c_voidp) == 8 else 32 + +controller_path = Path(__file__).parents[2].resolve() / "controllerClient" + +DLL_PATH = controller_path / f"x{64 if bits == 64 else 86}" / f"nvdaControllerClient{bits}.dll" + +libc = ct.CDLL(str(DLL_PATH)) diff --git a/src/nvda_voicemeeter/models.py b/src/nvda_voicemeeter/models.py new file mode 100644 index 0000000..03fb369 --- /dev/null +++ b/src/nvda_voicemeeter/models.py @@ -0,0 +1,26 @@ +def _make_cache(vm) -> dict: + match vm.kind.name: + case "basic": + return { + **{f"BUTTON||strip {i} A1": vm.strip[i].A1 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B1": vm.strip[i].B1 for i in range(vm.kind.num_strip)}, + } + case "banana": + return { + **{f"BUTTON||strip {i} A1": vm.strip[i].A1 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A2": vm.strip[i].A2 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A3": vm.strip[i].A3 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B1": vm.strip[i].B1 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B2": vm.strip[i].B2 for i in range(vm.kind.num_strip)}, + } + case "potato": + return { + **{f"BUTTON||strip {i} A1": vm.strip[i].A1 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A2": vm.strip[i].A2 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A3": vm.strip[i].A3 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A4": vm.strip[i].A4 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} A5": vm.strip[i].A5 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B1": vm.strip[i].B1 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B2": vm.strip[i].B2 for i in range(vm.kind.num_strip)}, + **{f"BUTTON||strip {i} B3": vm.strip[i].B3 for i in range(vm.kind.num_strip)}, + } diff --git a/src/nvda_voicemeeter/nvda.py b/src/nvda_voicemeeter/nvda.py new file mode 100644 index 0000000..31753ea --- /dev/null +++ b/src/nvda_voicemeeter/nvda.py @@ -0,0 +1,29 @@ +from .cdll import libc + + +class CBindings: + bind_test_if_running = libc.nvdaController_testIfRunning + bind_speak_text = libc.nvdaController_speakText + bind_cancel_speech = libc.nvdaController_cancelSpeech + bind_braille_message = libc.nvdaController_brailleMessage + + def call(self, fn, *args, ok=(0,)): + retval = fn(*args) + if retval not in ok: + raise RuntimeError(f"{fn.__name__} returned {retval}") + return retval + + +class Nvda(CBindings): + @property + def is_running(self): + return self.call(self.bind_test_if_running, ok=(0, 1)) == 0 + + def speak(self, text): + self.call(self.bind_speak_text, text) + + def cancel_speech(self): + self.call(self.bind_cancel_speech) + + def braille_message(self, text): + self.call(self.bind_braille_message, text) diff --git a/src/nvda_voicemeeter/parser.py b/src/nvda_voicemeeter/parser.py new file mode 100644 index 0000000..5661b57 --- /dev/null +++ b/src/nvda_voicemeeter/parser.py @@ -0,0 +1,10 @@ +from pyparsing import Group, OneOrMore, Optional, Suppress, Word, alphanums + + +class Parser: + def __init__(self): + self.widget = Group(OneOrMore(Word(alphanums))) + self.token = Suppress("||") + self.identifier = Group(OneOrMore(Word(alphanums))) + self.event = OneOrMore(Word(alphanums)) + self.match = self.widget + self.token + self.identifier + Optional(self.token) + Optional(self.event) diff --git a/src/nvda_voicemeeter/window.py b/src/nvda_voicemeeter/window.py new file mode 100644 index 0000000..37bf1f0 --- /dev/null +++ b/src/nvda_voicemeeter/window.py @@ -0,0 +1,60 @@ +import PySimpleGUI as psg + +from .models import _make_cache +from .nvda import Nvda +from .parser import Parser + + +class Window(psg.Window): + def __init__(self, title, vm): + self.vm = vm + self.kind = self.vm.kind + super().__init__(title, self.make_layout(), finalize=True) + self.cache = _make_cache(self.vm) + self.nvda = Nvda() + self.parser = Parser() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def make_layout(self) -> list: + """Builds the window layout step by step""" + + def add_physical_device_opts(layout): + devices = ["{type}: {name}".format(**self.vm.device.output(i)) for i in range(self.vm.device.outs)] + layout.append( + [ + psg.Combo( + devices, + size=(22, 4), + expand_x=True, + enable_events=True, + key=f"DEVICE LIST||PHYSOUT {i}", + ) + for i in range(self.kind.phys_out) + ] + ) + + upper_layout = list() + [step(upper_layout) for step in (add_physical_device_opts,)] + row0 = psg.Frame("Hardware Out", upper_layout) + + return [[row0]] + + def run(self): + """Runs the main window until an Close/Exit event""" + while True: + event, values = self.read() + if event in (psg.WIN_CLOSED, "Exit"): + break + match self.parser.match.parseString(event): + case _: + pass + + +def request_window_object(title, vm): + WINDOW_cls = Window + return WINDOW_cls(title, vm) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29