Compare commits

..

8 Commits
v1.1.0 ... dev

Author SHA1 Message Date
5fca7da033 patch bump 2026-03-20 02:05:15 +00:00
92f357f003 don't propogate control + arrow key if a slider is focused.
This fixes an issue where moving a slider may move the window as well.
2026-03-20 02:05:00 +00:00
d54995dbf1 patch bump 2026-03-20 00:36:39 +00:00
bc0b25032a fixes deprecation warning 2026-03-20 00:29:50 +00:00
2a86c05bea add ServerState enum to give is_running return values meaning.
fail faster if nvda isn't running
2026-03-20 00:29:39 +00:00
aae62fa136 the platform check is mostly redundant since import winreg will already have failed on most python installations.
Instead wrap `import winreg` and raise NVDAVMError

switch to ct.WinDLL which is more appropriate for C APIs using the stdcall convention
2026-03-19 23:58:20 +00:00
5b4a76c484 add task build preconditions 2026-03-17 00:26:27 +00:00
dfb96777bb remove emojis from release notes 2026-03-11 01:34:07 +00:00
7 changed files with 55 additions and 23 deletions

View File

@ -180,9 +180,9 @@ jobs:
- name: Build Summary
shell: pwsh
run: |
Write-Host -ForegroundColor Green "🎉 Build completed successfully!"
Write-Host -ForegroundColor Green "Build completed successfully!"
Write-Host ""
Write-Host "📦 Built artifacts:"
Write-Host "Built artifacts:"
Write-Host " - nvda-voicemeeter-basic.zip"
Write-Host " - nvda-voicemeeter-banana.zip"
Write-Host " - nvda-voicemeeter-potato.zip"
@ -208,22 +208,22 @@ jobs:
--title "NVDA-Voicemeeter $TAG_NAME" \
--notes "## NVDA-Voicemeeter Release $TAG_NAME
### 📦 Downloads
### Downloads
- **nvda-voicemeeter-basic.zip** - Basic version with dependencies
- **nvda-voicemeeter-banana.zip** - Banana version with dependencies
- **nvda-voicemeeter-potato.zip** - Potato version with dependencies
### 🔧 Requirements
### Requirements
- Windows 10/11
- Voicemeeter (Basic/Banana/Potato) installed
- NVDA screen reader
### 🚀 Installation
### Installation
1. Download the appropriate zip for your Voicemeeter version
2. Extract and run the executable - no installation required
3. The application will integrate with NVDA automatically
### 📝 Notes
### Notes
- Built with dynamic build system using PyInstaller
- Includes NVDA Controller Client for screen reader integration"
env:

View File

@ -22,6 +22,12 @@ tasks:
build:
desc: Build the project
deps: [generate-specs]
preconditions:
- sh: |
if [ ! -f controllerClient/x64/nvdaControllerClient.dll ] || [ ! -f controllerClient/x86/nvdaControllerClient.dll ]; then
exit 1
fi
msg: 'nvdaControllerClient.dll is missing. See https://github.com/nvaccess/nvda/blob/master/extras/controllerClient/readme.md for instructions on how to obtain it.'
cmds:
- for:
matrix:

View File

@ -1,6 +1,6 @@
[project]
name = "nvda-voicemeeter"
version = "1.1.0"
version = "1.1.2"
description = "A Voicemeeter app compatible with NVDA"
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
dependencies = [

View File

@ -1,15 +1,21 @@
import ctypes as ct
import platform
import winreg
from pathlib import Path
from .errors import NVDAVMError
BITS = 64 if ct.sizeof(ct.c_void_p) == 8 else 32
try:
import winreg
except ImportError as e:
ERR_MSG = 'winreg module not found, only Windows OS supported'
raise NVDAVMError(ERR_MSG) from e
# Defense against edge cases where winreg imports but we're not on Windows
if platform.system() != 'Windows':
raise NVDAVMError('Only Windows OS supported')
BITS = 64 if ct.sizeof(ct.c_void_p) == 8 else 32
REG_KEY = '\\'.join(
filter(
None,
@ -43,4 +49,4 @@ if not controller_path.exists():
DLL_PATH = controller_path / f'x{64 if BITS == 64 else 86}' / 'nvdaControllerClient.dll'
libc = ct.CDLL(str(DLL_PATH))
libc = ct.WinDLL(str(DLL_PATH))

View File

@ -1,7 +1,14 @@
from enum import IntEnum
from .cdll import libc
from .errors import NVDAVMCAPIError
class ServerState(IntEnum):
RUNNING = 0
UNAVAILABLE = 1722
class CBindings:
bind_test_if_running = libc.nvdaController_testIfRunning
bind_speak_text = libc.nvdaController_speakText
@ -18,7 +25,10 @@ class CBindings:
class Nvda(CBindings):
@property
def is_running(self):
return self.call(self.bind_test_if_running) == 0
return (
self.call(self.bind_test_if_running, ok=(ServerState.RUNNING, ServerState.UNAVAILABLE))
== ServerState.RUNNING
)
def speak(self, text):
self.call(self.bind_speak_text, text)

View File

@ -35,7 +35,7 @@ class Popup:
self.logger.debug(f'values::{values}')
if event in (psg.WIN_CLOSED, 'Cancel'):
break
match parsed_cmd := self.window.parser.match.parseString(event):
match parsed_cmd := self.window.parser.match.parse_string(event):
case [[button], ['FOCUS', 'IN']]:
if values['Browse']:
filepath = values['Browse']
@ -105,7 +105,7 @@ class Popup:
self.logger.debug(f'values::{values}')
if event in (psg.WIN_CLOSED, 'Cancel'):
break
match parsed_cmd := self.window.parser.match.parseString(event):
match parsed_cmd := self.window.parser.match.parse_string(event):
case [[button], ['FOCUS', 'IN']]:
self.window.nvda.speak(button)
case [_, ['KEY', 'ENTER']]:
@ -227,7 +227,7 @@ class Popup:
self.logger.debug(f'values::{values}')
if event in (psg.WIN_CLOSED, 'Exit'):
break
match parsed_cmd := self.window.parser.match.parseString(event):
match parsed_cmd := self.window.parser.match.parse_string(event):
case [['ASIO', 'INPUT', 'SPINBOX'], [in_num, channel]]:
index = util.get_asio_input_spinbox_index(int(channel), int(in_num[-1]))
val = values[f'ASIO INPUT SPINBOX||{in_num} {channel}']
@ -310,14 +310,16 @@ class Popup:
f'<Shift-{event}-{direction}>', f'||KEY SHIFT {direction.upper()} {event_id}'
)
self.popup[f'COMPRESSOR||SLIDER {param}'].bind(
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}'
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}', propagate=False
)
if param == 'RELEASE':
self.popup[f'COMPRESSOR||SLIDER {param}'].bind(
f'<Alt-{event}-{direction}>', f'||KEY ALT {direction.upper()} {event_id}'
)
self.popup[f'COMPRESSOR||SLIDER {param}'].bind(
f'<Control-Alt-{event}-{direction}>', f'||KEY CTRL ALT {direction.upper()} {event_id}'
f'<Control-Alt-{event}-{direction}>',
f'||KEY CTRL ALT {direction.upper()} {event_id}',
propagate=False,
)
self.popup[f'COMPRESSOR||SLIDER {param}'].bind('<Control-Shift-KeyPress-R>', '||KEY CTRL SHIFT R')
self.popup['MAKEUP'].bind('<FocusIn>', '||FOCUS IN')
@ -331,7 +333,7 @@ class Popup:
self.logger.debug(f'values::{values}')
if event in (psg.WIN_CLOSED, 'Exit'):
break
match parsed_cmd := self.window.parser.match.parseString(event):
match parsed_cmd := self.window.parser.match.parse_string(event):
case [['COMPRESSOR'], ['SLIDER', param]]:
setattr(self.window.vm.strip[index].comp, param.lower(), values[event])
case [['COMPRESSOR'], ['SLIDER', param], ['FOCUS', 'IN']]:
@ -642,14 +644,16 @@ class Popup:
f'<Shift-{event}-{direction}>', f'||KEY SHIFT {direction.upper()} {event_id}'
)
self.popup[f'GATE||SLIDER {param}'].bind(
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}'
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}', propagate=False
)
if param in ('BPSIDECHAIN', 'ATTACK', 'HOLD', 'RELEASE'):
self.popup[f'GATE||SLIDER {param}'].bind(
f'<Alt-{event}-{direction}>', f'||KEY ALT {direction.upper()} {event_id}'
)
self.popup[f'GATE||SLIDER {param}'].bind(
f'<Control-Alt-{event}-{direction}>', f'||KEY CTRL ALT {direction.upper()} {event_id}'
f'<Control-Alt-{event}-{direction}>',
f'||KEY CTRL ALT {direction.upper()} {event_id}',
propagate=False,
)
self.popup[f'GATE||SLIDER {param}'].bind('<Control-Shift-KeyPress-R>', '||KEY CTRL SHIFT R')
self.popup['Exit'].bind('<FocusIn>', '||FOCUS IN')
@ -661,7 +665,7 @@ class Popup:
self.logger.debug(f'values::{values}')
if event in (psg.WIN_CLOSED, 'Exit'):
break
match parsed_cmd := self.window.parser.match.parseString(event):
match parsed_cmd := self.window.parser.match.parse_string(event):
case [['GATE'], ['SLIDER', param]]:
setattr(self.window.vm.strip[index].gate, param.lower(), values[event])
case [['GATE'], ['SLIDER', param], ['FOCUS', 'IN']]:

View File

@ -6,6 +6,7 @@ import FreeSimpleGUI as psg
from . import configuration, models, util
from .builder import Builder
from .errors import NVDAVMError
from .nvda import Nvda
from .parser import Parser
from .popup import Popup
@ -25,6 +26,10 @@ class NVDAVMWindow(psg.Window):
self.kind = self.vm.kind
self.logger = logger.getChild(type(self).__name__)
self.logger.debug(f'loaded with theme: {psg.theme()}')
self.nvda = Nvda()
if not self.nvda.is_running:
self.logger.error('NVDA is not running. Exiting...')
raise NVDAVMError('NVDA is not running')
self.cache = {
'hw_ins': models._make_hardware_ins_cache(self.vm),
'hw_outs': models._make_hardware_outs_cache(self.vm),
@ -34,7 +39,6 @@ class NVDAVMWindow(psg.Window):
'asio': models._make_patch_asio_cache(self.vm),
'insert': models._make_patch_insert_cache(self.vm),
}
self.nvda = Nvda()
self.parser = Parser()
self.popup = Popup(self)
self.builder = Builder(self)
@ -247,7 +251,9 @@ class NVDAVMWindow(psg.Window):
f'<Shift-{event}-{direction}>', f'||KEY SHIFT {direction.upper()} {event_id}'
)
self[f'STRIP {i}||SLIDER {param}'].bind(
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}'
f'<Control-{event}-{direction}>',
f'||KEY CTRL {direction.upper()} {event_id}',
propagate=False,
)
self[f'STRIP {i}||SLIDER {param}'].bind('<Control-Shift-KeyPress-R>', '||KEY CTRL SHIFT R')
@ -280,7 +286,7 @@ class NVDAVMWindow(psg.Window):
f'<Shift-{event}-{direction}>', f'||KEY SHIFT {direction.upper()} {event_id}'
)
self[f'BUS {i}||SLIDER GAIN'].bind(
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}'
f'<Control-{event}-{direction}>', f'||KEY CTRL {direction.upper()} {event_id}', propagate=False
)
self[f'BUS {i}||SLIDER GAIN'].bind('<Control-Shift-KeyPress-R>', '||KEY CTRL SHIFT R')