Compare commits

...

22 Commits

Author SHA1 Message Date
10425f50d5 upd pre-commit config 2026-03-21 13:32:09 +00:00
7f327b4cce add braille support 2026-03-20 02:33:12 +00:00
2e3ba66b5a Nvda now longer inherits from CBindings
add wrapper methods in CBindings
2026-03-20 02:32:23 +00:00
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
5aa2af2acf minor bump 2026-03-11 01:11:23 +00:00
cc6e187998 bus mono now a ButtonMenu.
this allows users to select between  `mono off`, `mono on` and `stereo reverse`. This properly reflects the Voicemeter GUI.
2026-03-11 01:11:17 +00:00
1ea1c59f06 remove publish workflow 2026-03-11 00:49:24 +00:00
054ad040bb add publish+ruff workflows 2026-03-11 00:46:12 +00:00
94f0b847a7 copy only x64 and x86 dlls 2026-03-11 00:40:46 +00:00
8d251d1dea add missing compress step 2026-03-11 00:37:52 +00:00
5f7d66ceae set Task version 2026-03-11 00:29:31 +00:00
4ed17a5476 fix upload-artifact version 2026-03-11 00:26:15 +00:00
b88955a45a add release workflow 2026-03-11 00:23:37 +00:00
c3247fa5bf fix dynamic_builder 2026-03-10 23:34:18 +00:00
39c14279b2 add dynamic_builder.py script
add Taskfile.dynamic.yml for workflow builds.
2026-03-10 23:06:12 +00:00
13 changed files with 851 additions and 118 deletions

237
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,237 @@
name: Build and Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install PDM
uses: pdm-project/setup-pdm@v4
with:
python-version: '3.12'
- name: Install Task
uses: go-task/setup-task@v1
with:
version: 3.x
- name: Download NVDA Controller Client
shell: pwsh
run: |
Write-Host "Downloading NVDA Controller Client..."
$url = "https://download.nvaccess.org/releases/stable/nvda_2025.3.3_controllerClient.zip"
$zipPath = "nvda_controllerClient.zip"
# Download the zip file
Invoke-WebRequest -Uri $url -OutFile $zipPath
Write-Host "Downloaded $zipPath"
# Extract to temp directory
$tempDir = "temp_controller"
Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force
# Find and copy DLL files to correct locations
Write-Host "Extracting DLL files..."
# Create directories if they don't exist
New-Item -ItemType Directory -Path "controllerClient/x64" -Force | Out-Null
New-Item -ItemType Directory -Path "controllerClient/x86" -Force | Out-Null
# Find and copy the DLL files
$dllFiles = Get-ChildItem -Path $tempDir -Recurse -Name "*.dll" | Where-Object { $_ -like "*controllerClient*" }
foreach ($dll in $dllFiles) {
$fullPath = Join-Path $tempDir $dll
$dirName = (Get-Item $fullPath).Directory.Name
if ($dll -match "x64" -or $dirName -match "x64") {
Copy-Item $fullPath "controllerClient/x64/nvdaControllerClient.dll"
Write-Host "Copied x64 DLL: $dll"
} elseif ($dll -match "x86" -or $dirName -match "x86") {
Copy-Item $fullPath "controllerClient/x86/nvdaControllerClient.dll"
Write-Host "Copied x86 DLL: $dll"
} elseif ($dll -match "arm64" -or $dirName -match "arm64") {
Write-Host "Skipping ARM64 DLL: $dll (not needed)"
} else {
Write-Host "Skipping unknown architecture DLL: $dll"
}
}
# Clean up
Remove-Item $zipPath -Force
Remove-Item $tempDir -Recurse -Force
# Verify files were copied
Write-Host "Verifying controller client files..."
if (Test-Path "controllerClient/x64/nvdaControllerClient.dll") {
Write-Host "[OK] x64 controller client found"
} else {
Write-Host "[ERROR] x64 controller client missing"
exit 1
}
if (Test-Path "controllerClient/x86/nvdaControllerClient.dll") {
Write-Host "[OK] x86 controller client found"
} else {
Write-Host "[ERROR] x86 controller client missing"
exit 1
}
- name: Fix dependencies for CI
shell: pwsh
run: |
echo "Fixing local dependencies for CI build..."
# Remove local path dependency for voicemeeter-api
pdm remove -dG dev voicemeeter-api || true
echo "Updated dependencies for CI build"
- name: Install dependencies
shell: pwsh
run: |
# Install project dependencies
pdm install
# Verify PyInstaller is available
Write-Host "Verifying PyInstaller installation..."
pdm list | Select-String pyinstaller
- name: Get PDM executable path
shell: pwsh
run: |
$pdmPath = Get-Command pdm | Select-Object -ExpandProperty Source
Write-Host "PDM path: $pdmPath"
echo "PDM_BIN=$pdmPath" >> $env:GITHUB_ENV
- name: Build artifacts with dynamic taskfile
shell: pwsh
env:
PDM_BIN: ${{ env.PDM_BIN }}
run: |
Write-Host "Building all executables using dynamic builder..."
task --taskfile Taskfile.dynamic.yml build-all
- name: Compress build artifacts
shell: pwsh
env:
PDM_BIN: ${{ env.PDM_BIN }}
run: |
Write-Host "Compressing build artifacts..."
task --taskfile Taskfile.dynamic.yml compress-all
- name: Verify build outputs
shell: pwsh
run: |
Write-Host "Verifying build outputs..."
$expectedFiles = @(
"dist/basic.zip",
"dist/banana.zip",
"dist/potato.zip"
)
$missingFiles = @()
$foundFiles = @()
foreach ($file in $expectedFiles) {
if (Test-Path $file) {
$size = [math]::Round((Get-Item $file).Length / 1MB, 2)
$foundFiles += "$file ($size MB)"
} else {
$missingFiles += $file
}
}
Write-Host "Found files:"
$foundFiles | ForEach-Object { Write-Host " $_" }
if ($missingFiles.Count -gt 0) {
Write-Host -ForegroundColor Red "Missing files:"
$missingFiles | ForEach-Object { Write-Host " $_" }
exit 1
}
Write-Host -ForegroundColor Green "All expected files found!"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: nvda-voicemeeter-builds
path: |
dist/basic.zip
dist/banana.zip
dist/potato.zip
- name: Build Summary
shell: pwsh
run: |
Write-Host -ForegroundColor Green "Build completed successfully!"
Write-Host ""
Write-Host "Built artifacts:"
Write-Host " - nvda-voicemeeter-basic.zip"
Write-Host " - nvda-voicemeeter-banana.zip"
Write-Host " - nvda-voicemeeter-potato.zip"
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Create Release
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
gh release create $TAG_NAME \
--title "NVDA-Voicemeeter $TAG_NAME" \
--notes "## NVDA-Voicemeeter Release $TAG_NAME
### 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
- Windows 10/11
- Voicemeeter (Basic/Banana/Potato) installed
- NVDA screen reader
### 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
- Built with dynamic build system using PyInstaller
- Includes NVDA Controller Client for screen reader integration"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release assets
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
find . -name "*.zip" -exec gh release upload $TAG_NAME {} \;
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

19
.github/workflows/ruff.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Ruff
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
with:
args: 'format --check --diff'

View File

@@ -1,4 +1,11 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pdm-project/pdm
rev: 2.26.6
hooks:

76
Taskfile.dynamic.yml Normal file
View File

@@ -0,0 +1,76 @@
version: '3'
# Dynamic build system - no spec files needed!
# Examples:
# - task -t Taskfile.dynamic.yml build KINDS="basic banana"
# - task -t Taskfile.dynamic.yml build-all
# KINDS can be a space-separated list of kinds to build, or "all" to build everything.
#
# Compression tasks are also dynamic, allowing you to specify which kind to compress or compress all kinds at once.
# Examples:
# - task -t Taskfile.dynamic.yml compress KIND=basic
# - task -t Taskfile.dynamic.yml compress-all
vars:
KINDS: '{{.KINDS | default "all"}}'
SHELL: pwsh
tasks:
build:
desc: Build specified kinds dynamically (no spec files needed)
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:
- ${PDM_BIN:-pdm} run python tools/dynamic_builder.py {{.KINDS}}
build-all:
desc: Build all kinds
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:
- ${PDM_BIN:-pdm} run python tools/dynamic_builder.py all
compress:
desc: Compress artifacts for specified kind
cmds:
- task: compress-{{.KIND}}
compress-all:
desc: Compress artifacts for all kinds
cmds:
- for:
matrix:
KIND: [basic, banana, potato]
task: compress-{{.ITEM.KIND}}
compress-basic:
desc: Compress basic build artifacts
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/basic -DestinationPath dist/basic.zip -Force"'
generates:
- dist/basic.zip
compress-banana:
desc: Compress banana build artifacts
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/banana -DestinationPath dist/banana.zip -Force"'
generates:
- dist/banana.zip
compress-potato:
desc: Compress potato build artifacts
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/potato -DestinationPath dist/potato.zip -Force"'
generates:
- dist/potato.zip
clean:
desc: Clean all build artifacts
cmds:
- |
{{.SHELL}} -Command "Remove-Item -Path build/*,dist/* -Recurse -Force -ErrorAction SilentlyContinue"

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.0.0"
version = "1.1.2"
description = "A Voicemeeter app compatible with NVDA"
authors = [{ name = "Onyx and Iris", email = "code@onyxandiris.online" }]
dependencies = [

View File

@@ -438,13 +438,20 @@ class Builder:
def make_tab3_button_row(self, i) -> psg.Frame:
"""tab3 row represents bus composite toggle"""
def add_strip_outputs(layout):
params = ['MONO', 'EQ', 'MUTE']
def add_bus_buttons(layout):
busmono = util.get_bus_mono()
params = ['EQ', 'MUTE']
if self.kind.name == 'basic':
params.remove('EQ')
busmodes = [util._bus_mode_map[mode] for mode in util.get_bus_modes(self.vm)]
layout.append(
[
psg.ButtonMenu(
'Mono',
size=(6, 2),
menu_def=['', busmono],
key=f'BUS {i}||MONO',
),
*[
psg.Button(
param.capitalize(),
@@ -454,7 +461,7 @@ class Builder:
for param in params
],
psg.ButtonMenu(
'BUSMODE',
'Bus Mode',
size=(12, 2),
menu_def=['', busmodes],
key=f'BUS {i}||MODE',
@@ -463,7 +470,7 @@ class Builder:
)
outputs = []
[step(outputs) for step in (add_strip_outputs,)]
[step(outputs) for step in (add_bus_buttons,)]
return psg.Frame(
self.window.cache['labels'][f'BUS {i}||LABEL'],
outputs,

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,30 +1,56 @@
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
bind_cancel_speech = libc.nvdaController_cancelSpeech
bind_braille_message = libc.nvdaController_brailleMessage
def call(self, fn, *args, ok=(0,)):
def _call(self, fn, *args, ok=(0,)) -> int:
retval = fn(*args)
if retval not in ok:
raise NVDAVMCAPIError(fn.__name__, retval)
return retval
def test_if_running(self) -> int:
return self._call(self.bind_test_if_running, ok=(ServerState.RUNNING, ServerState.UNAVAILABLE))
def speak_text(self, text: str) -> None:
self._call(self.bind_speak_text, text)
def cancel_speech(self) -> None:
self._call(self.bind_cancel_speech)
def braille_message(self, text: str) -> None:
self._call(self.bind_braille_message, text)
class Nvda:
def __init__(self):
self._bindings = CBindings()
class Nvda(CBindings):
@property
def is_running(self):
return self.call(self.bind_test_if_running) == 0
def is_running(self) -> bool:
return self._bindings.test_if_running() == ServerState.RUNNING
def speak(self, text):
self.call(self.bind_speak_text, text)
def speak(self, text: str) -> None:
self._bindings.speak_text(text)
def cancel_speech(self):
self.call(self.bind_cancel_speech)
def cancel_speech(self) -> None:
self._bindings.cancel_speech()
def braille_message(self, text):
self.call(self.bind_braille_message, text)
def braille_message(self, text: str) -> None:
self._bindings.braille_message(text)
def speak_and_braille(self, text: str) -> None:
self.speak(text)
self.braille_message(text)

View File

@@ -35,12 +35,12 @@ 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']
break
self.window.nvda.speak(button)
self.window.nvda.speak_and_braille(button)
case [_, ['KEY', 'ENTER']]:
popup.find_element_with_focus().click()
self.logger.debug(f'parsed::{parsed_cmd}')
@@ -105,9 +105,9 @@ 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)
self.window.nvda.speak_and_braille(button)
case [_, ['KEY', 'ENTER']]:
popup.find_element_with_focus().click()
case ['Ok']:
@@ -227,27 +227,27 @@ 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}']
self.window.vm.patch.asio[index].set(val)
channel = ('left', 'right')[int(channel)]
self.window.nvda.speak(str(val))
self.window.nvda.speak_and_braille(str(val))
case [['ASIO', 'INPUT', 'SPINBOX'], [in_num, channel], ['FOCUS', 'IN']]:
if self.popup.find_element_with_focus() is not None:
val = values[f'ASIO INPUT SPINBOX||{in_num} {channel}']
channel = ('left', 'right')[int(channel)]
num = int(in_num[-1])
self.window.nvda.speak(f'Patch ASIO inputs to strips IN#{num} {channel} {val}')
self.window.nvda.speak_and_braille(f'Patch ASIO inputs to strips IN#{num} {channel} {val}')
case [['ASIO', 'OUTPUT', param, 'SPINBOX'], [index]]:
target = getattr(self.window.vm.patch, param)[int(index)]
target.set(values[event])
self.window.nvda.speak(str(values[event]))
self.window.nvda.speak_and_braille(str(values[event]))
case [['ASIO', 'OUTPUT', param, 'SPINBOX'], [index], ['FOCUS', 'IN']]:
if self.popup.find_element_with_focus() is not None:
val = values[f'ASIO OUTPUT {param} SPINBOX||{index}']
self.window.nvda.speak(
self.window.nvda.speak_and_braille(
f'Patch BUS to A1 ASIO Outputs OUT {param} channel {int(index) + 1} {val}'
)
case ['BUFFER MME' | 'BUFFER WDM' | 'BUFFER KS' | 'BUFFER ASIO']:
@@ -263,15 +263,15 @@ class Popup:
driver = event.split()[1]
self.window.vm.set(f'option.buffer.{driver.lower()}', val)
self.window.TKroot.after(
200, self.window.nvda.speak, f'{driver} BUFFER {val if val else "default"}'
200, self.window.nvda.speak_and_braille, f'{driver} BUFFER {val if val else "default"}'
)
case [['BUFFER', driver], ['FOCUS', 'IN']]:
val = int(self.window.vm.get(f'option.buffer.{driver.lower()}'))
self.window.nvda.speak(f'{driver} BUFFER {val if val else "default"}')
self.window.nvda.speak_and_braille(f'{driver} BUFFER {val if val else "default"}')
case [['BUFFER', driver], ['KEY', 'SPACE' | 'ENTER']]:
util.open_context_menu_for_buttonmenu(self.popup, f'BUFFER {driver}')
case [[button], ['FOCUS', 'IN']]:
self.window.nvda.speak(button)
self.window.nvda.speak_and_braille(button)
case [_, ['KEY', 'ENTER']]:
self.popup.find_element_with_focus().click()
self.logger.debug(f'parsed::{parsed_cmd}')
@@ -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,11 +333,11 @@ 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']]:
self.window.nvda.speak(f'{param} {values[f"COMPRESSOR||SLIDER {param}"]}')
self.window.nvda.speak_and_braille(f'{param} {values[f"COMPRESSOR||SLIDER {param}"]}')
case [
['COMPRESSOR'],
['SLIDER', param],
@@ -362,9 +364,9 @@ class Popup:
setattr(self.window.vm.strip[index].comp, param.lower(), val)
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
if param == 'KNEE':
self.window.nvda.speak(str(round(val, 2)))
self.window.nvda.speak_and_braille(str(round(val, 2)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -397,9 +399,9 @@ class Popup:
setattr(self.window.vm.strip[index].comp, param.lower(), val)
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
if param == 'KNEE':
self.window.nvda.speak(str(round(val, 2)))
self.window.nvda.speak_and_braille(str(round(val, 2)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -428,9 +430,9 @@ class Popup:
setattr(self.window.vm.strip[index].comp, param.lower(), val)
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
if param == 'KNEE':
self.window.nvda.speak(str(round(val, 2)))
self.window.nvda.speak_and_braille(str(round(val, 2)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -451,7 +453,7 @@ class Popup:
val = util.check_bounds(val, (0, 5000))
self.window.vm.strip[index].comp.release = val
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -472,7 +474,7 @@ class Popup:
val = util.check_bounds(val, (0, 5000))
self.window.vm.strip[index].comp.release = val
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
@@ -483,7 +485,7 @@ class Popup:
self.window.vm.strip[index].comp.gainout = values[event]
case [['COMPRESSOR'], ['SLIDER', 'INPUT' | 'OUTPUT' as direction, 'GAIN'], ['FOCUS', 'IN']]:
label = f'{direction} GAIN'
self.window.nvda.speak(f'{label} {values[f"COMPRESSOR||SLIDER {label}"]}')
self.window.nvda.speak_and_braille(f'{label} {values[f"COMPRESSOR||SLIDER {label}"]}')
case [
['COMPRESSOR'],
['SLIDER', 'INPUT' | 'OUTPUT' as direction, 'GAIN'],
@@ -508,7 +510,7 @@ class Popup:
else:
self.window.vm.strip[index].comp.gainout = val
self.popup[f'COMPRESSOR||SLIDER {direction} GAIN'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -535,7 +537,7 @@ class Popup:
else:
self.window.vm.strip[index].comp.gainout = val
self.popup[f'COMPRESSOR||SLIDER {direction} GAIN'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -562,7 +564,7 @@ class Popup:
else:
self.window.vm.strip[index].comp.gainout = val
self.popup[f'COMPRESSOR||SLIDER {direction} GAIN'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
@@ -576,7 +578,7 @@ class Popup:
else:
self.window.vm.strip[index].comp.gainout = 0
self.popup[f'COMPRESSOR||SLIDER {direction} GAIN'].update(value=0)
self.window.nvda.speak(str(0))
self.window.nvda.speak_and_braille(str(0))
case [['COMPRESSOR'], ['SLIDER', param], ['KEY', 'CTRL', 'SHIFT', 'R']]:
match param:
case 'RATIO':
@@ -591,19 +593,19 @@ class Popup:
val = 0.5
setattr(self.window.vm.strip[index].comp, param.lower(), val)
self.popup[f'COMPRESSOR||SLIDER {param}'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
case ['MAKEUP']:
val = not self.window.vm.strip[index].comp.makeup
self.window.vm.strip[index].comp.makeup = val
self.window.nvda.speak('on' if val else 'off')
self.window.nvda.speak_and_braille('on' if val else 'off')
case [[button], ['FOCUS', 'IN']]:
if button == 'MAKEUP':
self.window.nvda.speak(
self.window.nvda.speak_and_braille(
f'{button} {"on" if self.window.vm.strip[index].comp.makeup else "off"}'
)
else:
self.window.nvda.speak(button)
self.window.nvda.speak_and_braille(button)
case [_, ['KEY', 'ENTER']]:
self.popup.find_element_with_focus().click()
self.logger.debug(f'parsed::{parsed_cmd}')
@@ -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']]:
@@ -669,7 +673,9 @@ class Popup:
'DAMPING': 'Damping Max',
'BPSIDECHAIN': 'BP Sidechain',
}
self.window.nvda.speak(f'{label_map.get(param, param)} {values[f"GATE||SLIDER {param}"]}')
self.window.nvda.speak_and_braille(
f'{label_map.get(param, param)} {values[f"GATE||SLIDER {param}"]}'
)
case [
['GATE'],
@@ -691,9 +697,9 @@ class Popup:
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
if param == 'BPSIDECHAIN':
self.window.nvda.speak(str(int(val)))
self.window.nvda.speak_and_braille(str(int(val)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -716,9 +722,9 @@ class Popup:
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
if param == 'BPSIDECHAIN':
self.window.nvda.speak(str(int(val)))
self.window.nvda.speak_and_braille(str(int(val)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -741,9 +747,9 @@ class Popup:
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
if param == 'BPSIDECHAIN':
self.window.nvda.speak(str(int(val)))
self.window.nvda.speak_and_braille(str(int(val)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -765,9 +771,9 @@ class Popup:
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
if param == 'BPSIDECHAIN':
self.window.nvda.speak(str(int(val)))
self.window.nvda.speak_and_braille(str(int(val)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [
@@ -789,9 +795,9 @@ class Popup:
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
if param == 'BPSIDECHAIN':
self.window.nvda.speak(str(int(val)))
self.window.nvda.speak_and_braille(str(int(val)))
else:
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
else:
self.window.vm.event.pdirty = True
case [['GATE'], ['SLIDER', param], ['KEY', 'CTRL', 'SHIFT', 'R']]:
@@ -810,10 +816,10 @@ class Popup:
val = 1000
setattr(self.window.vm.strip[index].gate, param.lower(), val)
self.popup[f'GATE||SLIDER {param}'].update(value=val)
self.window.nvda.speak(str(round(val, 1)))
self.window.nvda.speak_and_braille(str(round(val, 1)))
case [[button], ['FOCUS', 'IN']]:
self.window.nvda.speak(button)
self.window.nvda.speak_and_braille(button)
case [_, ['KEY', 'ENTER']]:
self.popup.find_element_with_focus().click()

View File

@@ -155,6 +155,10 @@ def get_bus_modes(vm) -> list:
]
def get_bus_mono() -> list:
return ['off', 'on', 'stereo reverse']
def check_bounds(val, bounds: tuple) -> int | float:
lower, upper = bounds
if val > upper:

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)
@@ -58,6 +62,7 @@ class NVDAVMWindow(psg.Window):
self[f'STRIP {i}||SLIDER LIMIT'].Widget.config(**slider_opts)
for i in range(self.kind.num_bus):
self[f'BUS {i}||SLIDER GAIN'].Widget.config(**slider_opts)
self[f'BUS {i}||MONO'].Widget.config(**buttonmenu_opts)
self[f'BUS {i}||MODE'].Widget.config(**buttonmenu_opts)
self.register_events()
@@ -73,7 +78,7 @@ class NVDAVMWindow(psg.Window):
self.logger.debug(f'config {defaultconfig} loaded')
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'config {defaultconfig.stem} has been loaded',
)
except json.JSONDecodeError:
@@ -246,18 +251,23 @@ 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')
# Bus Params
params = ['MONO', 'EQ', 'MUTE']
params = ['EQ', 'MUTE']
if self.kind.name == 'basic':
params.remove('EQ')
for i in range(self.kind.num_bus):
for param in params:
self[f'BUS {i}||{param}'].bind('<FocusIn>', '||FOCUS IN')
self[f'BUS {i}||{param}'].bind('<Return>', '||KEY ENTER')
self[f'BUS {i}||MONO'].bind('<FocusIn>', '||FOCUS IN')
self[f'BUS {i}||MONO'].bind('<space>', '||KEY SPACE', propagate=False)
self[f'BUS {i}||MONO'].bind('<Return>', '||KEY ENTER')
self[f'BUS {i}||MODE'].bind('<FocusIn>', '||FOCUS IN')
self[f'BUS {i}||MODE'].bind('<space>', '||KEY SPACE', propagate=False)
self[f'BUS {i}||MODE'].bind('<Return>', '||KEY ENTER', propagate=False)
@@ -276,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')
@@ -296,17 +306,17 @@ class NVDAVMWindow(psg.Window):
break
elif event in util.get_slider_modes():
mode = event
self.nvda.speak(f'{mode} enabled')
self.nvda.speak_and_braille(f'{mode} enabled')
self.logger.debug(f'entered slider mode {mode}')
continue
elif event == 'ESCAPE':
if mode:
self.nvda.speak(f'{mode} disabled')
self.nvda.speak_and_braille(f'{mode} disabled')
self.logger.debug(f'exited from slider mode {mode}')
mode = None
continue
match parsed_cmd := self.parser.match.parseString(event):
match parsed_cmd := self.parser.match.parse_string(event):
# Slider mode
case [['ALT', 'LEFT' | 'RIGHT' | 'UP' | 'DOWN' as direction], ['PRESS' | 'RELEASE' as e]]:
if mode:
@@ -321,7 +331,7 @@ class NVDAVMWindow(psg.Window):
# Focus tabgroup
case ['CTRL-TAB'] | ['CTRL-SHIFT-TAB']:
self['tabgroup'].set_focus()
self.nvda.speak(f'{values["tabgroup"]}')
self.nvda.speak_and_braille(f'{values["tabgroup"]}')
# Quick Navigation
case ['CTRL-1' | 'CTRL-2' | 'CTRL-3' | 'CTRL-4' | 'CTRL-5' | 'CTRL-6' | 'CTRL-7' | 'CTRL-8' as bind]:
@@ -465,7 +475,7 @@ class NVDAVMWindow(psg.Window):
case [['ENGINE', 'RESTART'], ['END']]:
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
'Audio Engine restarted',
)
case [['Save', 'Settings'], ['MENU']]:
@@ -477,7 +487,7 @@ class NVDAVMWindow(psg.Window):
self.logger.debug(f'saving config file to {filepath}')
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'config file {filepath.stem} has been saved',
)
case [['Load', 'Settings'], ['MENU']]:
@@ -496,7 +506,7 @@ class NVDAVMWindow(psg.Window):
self.TKroot.after(i, self.on_pdirty)
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'config file {filepath.stem} has been loaded',
)
case [['Load', 'Settings', 'on', 'Startup'], ['MENU']]:
@@ -512,7 +522,7 @@ class NVDAVMWindow(psg.Window):
configuration.set('default_config', str(filepath))
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'config {filepath.stem} set as default on startup',
)
else:
@@ -526,7 +536,7 @@ class NVDAVMWindow(psg.Window):
configuration.set('default_theme', chosen)
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'theme {chosen} selected.',
)
self.logger.debug(f'theme {chosen} selected')
@@ -534,13 +544,13 @@ class NVDAVMWindow(psg.Window):
# Tabs
case ['tabgroup'] | [['tabgroup'], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is None:
self.nvda.speak(f'{values["tabgroup"]}')
self.nvda.speak_and_braille(f'{values["tabgroup"]}')
case [['tabgroup'], tabname] | [['tabgroup'], tabname, ['FOCUS', 'IN']]:
if self.find_element_with_focus() is None:
name = ' '.join(tabname)
self.nvda.speak(f'{values[f"tabgroup||{name}"]}')
self.nvda.speak_and_braille(f'{values[f"tabgroup||{name}"]}')
case [['tabgroup'], _, ['KEY', 'SHIFT', 'TAB']]:
self.nvda.speak(values['tabgroup'])
self.nvda.speak_and_braille(values['tabgroup'])
# Hardware In
case [['HARDWARE', 'IN'], [key]]:
@@ -549,18 +559,22 @@ class NVDAVMWindow(psg.Window):
match selection.split(':'):
case [device_name]:
setattr(self.vm.strip[index].device, 'wdm', '')
self.TKroot.after(200, self.nvda.speak, f'HARDWARE IN {key} device selection removed')
self.TKroot.after(
200, self.nvda.speak_and_braille, f'HARDWARE IN {key} device selection removed'
)
case [driver, device_name]:
setattr(self.vm.strip[index].device, driver, device_name.lstrip())
phonetic = {'mme': 'em em e'}
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'HARDWARE IN {key} set {phonetic.get(driver, driver)} {device_name}',
)
case [['HARDWARE', 'IN'], [key], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is not None:
self.nvda.speak(f'HARDWARE INPUT {key} {self.cache["hw_ins"][f"HARDWARE IN||{key}"]}')
self.nvda.speak_and_braille(
f'HARDWARE INPUT {key} {self.cache["hw_ins"][f"HARDWARE IN||{key}"]}'
)
case [['HARDWARE', 'IN'], [key], ['KEY', 'SPACE' | 'ENTER']]:
util.open_context_menu_for_buttonmenu(self, f'HARDWARE IN||{key}')
@@ -571,18 +585,22 @@ class NVDAVMWindow(psg.Window):
match selection.split(':'):
case [device_name]:
setattr(self.vm.bus[index].device, 'wdm', '')
self.TKroot.after(200, self.nvda.speak, f'HARDWARE OUT {key} device selection removed')
self.TKroot.after(
200, self.nvda.speak_and_braille, f'HARDWARE OUT {key} device selection removed'
)
case [driver, device_name]:
setattr(self.vm.bus[index].device, driver, device_name.lstrip())
phonetic = {'mme': 'em em e'}
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
f'HARDWARE OUT {key} set {phonetic.get(driver, driver)} {device_name}',
)
case [['HARDWARE', 'OUT'], [key], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is not None:
self.nvda.speak(f'HARDWARE OUT {key} {self.cache["hw_outs"][f"HARDWARE OUT||{key}"]}')
self.nvda.speak_and_braille(
f'HARDWARE OUT {key} {self.cache["hw_outs"][f"HARDWARE OUT||{key}"]}'
)
case [['HARDWARE', 'OUT'], [key], ['KEY', 'SPACE' | 'ENTER']]:
util.open_context_menu_for_buttonmenu(self, f'HARDWARE OUT||{key}')
@@ -591,7 +609,7 @@ class NVDAVMWindow(psg.Window):
val = values[f'PATCH COMPOSITE||{key}']
index = int(key[-1]) - 1
self.vm.patch.composite[index].set(util.get_patch_composite_list(self.kind).index(val) + 1)
self.TKroot.after(200, self.nvda.speak, val)
self.TKroot.after(200, self.nvda.speak_and_braille, val)
case [['PATCH', 'COMPOSITE'], [key], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is not None:
if values[f'PATCH COMPOSITE||{key}']:
@@ -605,7 +623,7 @@ class NVDAVMWindow(psg.Window):
except IndexError as e:
val = comp_list[-1]
self.logger.error(f'{type(e).__name__}: {e}')
self.nvda.speak(f'Patch COMPOSITE {key[-1]} {val}')
self.nvda.speak_and_braille(f'Patch COMPOSITE {key[-1]} {val}')
case [['PATCH', 'COMPOSITE'], [key], ['KEY', 'SPACE' | 'ENTER']]:
util.open_context_menu_for_buttonmenu(self, f'PATCH COMPOSITE||{key}')
@@ -618,7 +636,7 @@ class NVDAVMWindow(psg.Window):
)
val = values[f'INSERT CHECKBOX||{in_num} {channel}']
self.vm.patch.insert[index].on = val
self.nvda.speak('on' if val else 'off')
self.nvda.speak_and_braille('on' if val else 'off')
case [['INSERT', 'CHECKBOX'], [in_num, channel], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is not None:
index = util.get_insert_checkbox_index(
@@ -629,7 +647,7 @@ class NVDAVMWindow(psg.Window):
val = values[f'INSERT CHECKBOX||{in_num} {channel}']
channel = util._patch_insert_channels[int(channel)]
num = int(in_num[-1])
self.nvda.speak(f'Patch INSERT IN#{num} {channel} {"on" if val else "off"}')
self.nvda.speak_and_braille(f'Patch INSERT IN#{num} {channel} {"on" if val else "off"}')
case [['INSERT', 'CHECKBOX'], [in_num, channel], ['KEY', 'ENTER']]:
val = not values[f'INSERT CHECKBOX||{in_num} {channel}']
self.write_event_value(f'INSERT CHECKBOX||{in_num} {channel}', val)
@@ -639,7 +657,7 @@ class NVDAVMWindow(psg.Window):
if values['tabgroup'] == 'tab||Settings':
self.popup.advanced_settings(title='Advanced Settings')
case [['ADVANCED', 'SETTINGS'], ['FOCUS', 'IN']]:
self.nvda.speak('ADVANCED SETTINGS')
self.nvda.speak_and_braille('ADVANCED SETTINGS')
case [['ADVANCED', 'SETTINGS'], ['KEY', 'ENTER']]:
self.find_element_with_focus().click()
@@ -653,28 +671,30 @@ class NVDAVMWindow(psg.Window):
next_val = 0
self.vm.strip[int(index)].k = next_val
self.cache['strip'][f'STRIP {index}||{param}'] = next_val
self.nvda.speak(opts[next_val])
self.nvda.speak_and_braille(opts[next_val])
case output if param in util._get_bus_assignments(self.kind):
val = not self.cache['strip'][f'STRIP {index}||{output}']
setattr(self.vm.strip[int(index)], output, val)
self.cache['strip'][f'STRIP {index}||{output}'] = val
self.nvda.speak('on' if val else 'off')
self.nvda.speak_and_braille('on' if val else 'off')
case _:
val = not self.cache['strip'][f'STRIP {index}||{param}']
setattr(self.vm.strip[int(index)], param.lower(), val)
self.cache['strip'][f'STRIP {index}||{param}'] = val
self.nvda.speak('on' if val else 'off')
self.nvda.speak_and_braille('on' if val else 'off')
case [['STRIP', index], [param], ['FOCUS', 'IN']]:
if self.find_element_with_focus() is not None:
val = self.cache['strip'][f'STRIP {index}||{param}']
phonetic = {'KARAOKE': 'karaoke'}
label = self.cache['labels'][f'STRIP {index}||LABEL']
if param == 'KARAOKE':
self.nvda.speak(
self.nvda.speak_and_braille(
f'{label} {phonetic.get(param, param)} {["off", "k m", "k 1", "k 2", "k v"][self.cache["strip"][f"STRIP {int(index)}||{param}"]]}'
)
else:
self.nvda.speak(f'{label} {phonetic.get(param, param)} {"on" if val else "off"}')
self.nvda.speak_and_braille(
f'{label} {phonetic.get(param, param)} {"on" if val else "off"}'
)
case [['STRIP', index], [param], ['KEY', 'ENTER']]:
self.find_element_with_focus().click()
@@ -727,7 +747,7 @@ class NVDAVMWindow(psg.Window):
if self.find_element_with_focus() is not None:
val = values[f'STRIP {index}||SLIDER {param}']
label = self.cache['labels'][f'STRIP {index}||LABEL']
self.nvda.speak(f'{label} {param} {int(val) if param == "LIMIT" else val}')
self.nvda.speak_and_braille(f'{label} {param} {int(val) if param == "LIMIT" else val}')
case [
['STRIP', index],
[
@@ -795,7 +815,7 @@ class NVDAVMWindow(psg.Window):
val = util.check_bounds(val, (-40, 12))
self.vm.strip[int(index)].limit = val
self[f'STRIP {index}||SLIDER {param}'].update(value=val)
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [
@@ -863,9 +883,9 @@ class NVDAVMWindow(psg.Window):
self.vm.strip[int(index)].limit = val
self[f'STRIP {index}||SLIDER {param}'].update(value=val)
if param == 'LIMIT':
self.nvda.speak(str(int(val)))
self.nvda.speak_and_braille(str(int(val)))
else:
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [
@@ -933,9 +953,9 @@ class NVDAVMWindow(psg.Window):
self.vm.strip[int(index)].limit = val
self[f'STRIP {index}||SLIDER {param}'].update(value=val)
if param == 'LIMIT':
self.nvda.speak(str(int(val)))
self.nvda.speak_and_braille(str(int(val)))
else:
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [['STRIP', index], ['SLIDER', param], ['KEY', 'CTRL', 'SHIFT', 'R']]:
@@ -956,7 +976,7 @@ class NVDAVMWindow(psg.Window):
case 'LIMIT':
self.vm.strip[int(index)].limit = 12
self[f'STRIP {index}||SLIDER {param}'].update(value=12)
self.nvda.speak(f'{12 if param == "LIMIT" else 0}')
self.nvda.speak_and_braille(f'{12 if param == "LIMIT" else 0}')
# Bus Params
case [['BUS', index], [param]]:
@@ -969,25 +989,34 @@ class NVDAVMWindow(psg.Window):
self.cache['bus'][event] = val
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
'on' if val else 'off',
)
case 'MONO' | 'MUTE':
case 'MUTE':
val = not val
setattr(self.vm.bus[int(index)], param.lower(), val)
self.cache['bus'][event] = val
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
'on' if val else 'off',
)
case 'MONO':
chosen = values[event]
self.vm.bus[int(index)].mono = util.get_bus_mono().index(chosen)
self.cache['bus'][event] = chosen
self.TKroot.after(
200,
self.nvda.speak_and_braille,
f'mono {chosen}',
)
case 'MODE':
chosen = util._bus_mode_map_reversed[values[event]]
setattr(self.vm.bus[int(index)].mode, chosen, True)
self.cache['bus'][event] = chosen
self.TKroot.after(
200,
self.nvda.speak,
self.nvda.speak_and_braille,
util._bus_mode_map[chosen],
)
case [['BUS', index], [param], ['FOCUS', 'IN']]:
@@ -995,12 +1024,20 @@ class NVDAVMWindow(psg.Window):
label = self.cache['labels'][f'BUS {index}||LABEL']
val = self.cache['bus'][f'BUS {index}||{param}']
if param == 'MODE':
self.nvda.speak(f'{label} bus {param} {util._bus_mode_map[val]}')
self.nvda.speak_and_braille(f'{label} bus {param} {util._bus_mode_map[val]}')
elif param == 'MONO':
busmode = util.get_bus_mono()[val]
if busmode in ('on', 'off'):
self.nvda.speak_and_braille(f'{label} {param} {busmode}')
else:
self.nvda.speak(f'{label} {param} {"on" if val else "off"}')
self.nvda.speak_and_braille(f'{label} {busmode}')
else:
self.nvda.speak_and_braille(f'{label} {param} {"on" if val else "off"}')
case [['BUS', index], [param], ['KEY', 'SPACE' | 'ENTER']]:
if param == 'MODE':
util.open_context_menu_for_buttonmenu(self, f'BUS {index}||MODE')
elif param == 'MONO':
util.open_context_menu_for_buttonmenu(self, f'BUS {index}||MONO')
else:
self.find_element_with_focus().click()
@@ -1013,7 +1050,7 @@ class NVDAVMWindow(psg.Window):
if self.find_element_with_focus() is not None:
label = self.cache['labels'][f'BUS {index}||LABEL']
val = values[f'BUS {index}||SLIDER GAIN']
self.nvda.speak(f'{label} gain {val}')
self.nvda.speak_and_braille(f'{label} gain {val}')
case [['BUS', index], ['SLIDER', 'GAIN'], ['FOCUS', 'OUT']]:
pass
case [
@@ -1032,7 +1069,7 @@ class NVDAVMWindow(psg.Window):
val = util.check_bounds(val, (-60, 12))
self.vm.bus[int(index)].gain = val
self[f'BUS {index}||SLIDER GAIN'].update(value=val)
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [
@@ -1051,7 +1088,7 @@ class NVDAVMWindow(psg.Window):
val = util.check_bounds(val, (-60, 12))
self.vm.bus[int(index)].gain = val
self[f'BUS {index}||SLIDER GAIN'].update(value=val)
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [
@@ -1070,13 +1107,13 @@ class NVDAVMWindow(psg.Window):
val = util.check_bounds(val, (-60, 12))
self.vm.bus[int(index)].gain = val
self[f'BUS {index}||SLIDER GAIN'].update(value=val)
self.nvda.speak(str(round(val, 1)))
self.nvda.speak_and_braille(str(round(val, 1)))
else:
self.vm.event.pdirty = True
case [['BUS', index], ['SLIDER', 'GAIN'], ['KEY', 'CTRL', 'SHIFT', 'R']]:
self.vm.bus[int(index)].gain = 0
self[f'BUS {index}||SLIDER GAIN'].update(value=0)
self.nvda.speak(str(0))
self.nvda.speak_and_braille(str(0))
# Unknown
case _:

302
tools/dynamic_builder.py Normal file
View File

@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
Dynamic build system for nvda-voicemeeter.
This script generates PyInstaller spec files on-the-fly and builds executables
without storing intermediate files in the repository.
Usage:
python tools/dynamic_builder.py # Build all kinds
python tools/dynamic_builder.py basic # Build basic only
python tools/dynamic_builder.py basic banana # Build specific kinds
python tools/dynamic_builder.py all # Build all kinds (explicit)
Requirements:
- PDM environment with PyInstaller installed
- controllerClient DLL files in controllerClient/{x64,x86}/
- nvda_voicemeeter package installed in environment
Environment Variables:
- PDM_BIN: Path to PDM executable (default: 'pdm')
Exit Codes:
- 0: All builds successful
- 1: One or more builds failed
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Dict
# Build configuration
KINDS = ['basic', 'banana', 'potato']
# Templates
PYTHON_TEMPLATE = """import voicemeeterlib
import nvda_voicemeeter
KIND_ID = '{kind}'
with voicemeeterlib.api(KIND_ID) as vm:
with nvda_voicemeeter.draw(KIND_ID, vm) as window:
window.run()
"""
SPEC_TEMPLATE = """# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
added_files = [
( '{controller_x64_path}', 'controllerClient/x64' ),
( '{controller_x86_path}', 'controllerClient/x86' ),
]
a = Analysis(
['{script_path}'],
pathex=[],
binaries=[],
datas=added_files,
hiddenimports=[],
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='{kind}',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='{kind}',
)
"""
class DynamicBuilder:
def __init__(self, base_dir: Path, dist_dir: Path):
self.base_dir = base_dir
self.dist_dir = dist_dir
self.temp_dir = None
def validate_environment(self) -> bool:
"""Validate that all required files and dependencies are present."""
# Check for controller client DLLs
x64_dll = self.base_dir / 'controllerClient' / 'x64' / 'nvdaControllerClient.dll'
x86_dll = self.base_dir / 'controllerClient' / 'x86' / 'nvdaControllerClient.dll'
if not x64_dll.exists():
print(f'[ERROR] Missing x64 controller client: {x64_dll}')
return False
if not x86_dll.exists():
print(f'[ERROR] Missing x86 controller client: {x86_dll}')
return False
print('[OK] Controller client DLLs found')
# Check PyInstaller availability
try:
result = subprocess.run(['pdm', 'list'], capture_output=True, text=True)
if 'pyinstaller' not in result.stdout.lower():
print('[ERROR] PyInstaller not found in PDM environment')
return False
print('[OK] PyInstaller available')
except subprocess.CalledProcessError:
print('[ERROR] Failed to check PDM environment')
return False
return True
def __enter__(self):
# Validate environment first
if not self.validate_environment():
print('[ERROR] Environment validation failed!')
sys.exit(1)
self.temp_dir = Path(tempfile.mkdtemp(prefix='nvda_voicemeeter_build_'))
print(f'Using temp directory: {self.temp_dir}')
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.temp_dir and self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
print(f'Cleaned up temp directory: {self.temp_dir}')
def create_python_file(self, kind: str) -> Path:
"""Create a temporary Python launcher file."""
content = PYTHON_TEMPLATE.format(kind=kind)
py_file = self.temp_dir / f'{kind}.py'
with open(py_file, 'w') as f:
f.write(content)
return py_file
def create_spec_file(self, kind: str, py_file: Path) -> Path:
"""Create a temporary PyInstaller spec file."""
controller_x64_path = (self.base_dir / 'controllerClient' / 'x64').as_posix()
controller_x86_path = (self.base_dir / 'controllerClient' / 'x86').as_posix()
content = SPEC_TEMPLATE.format(
script_path=py_file.as_posix(),
controller_x64_path=controller_x64_path,
controller_x86_path=controller_x86_path,
kind=kind,
)
spec_file = self.temp_dir / f'{kind}.spec'
with open(spec_file, 'w') as f:
f.write(content)
return spec_file
def build_variant(self, kind: str) -> bool:
"""Build a single kind variant."""
print(f'Building {kind}...')
# Validate kind
if kind not in KINDS:
print(f'[ERROR] Unknown kind: {kind}. Valid kinds: {", ".join(KINDS)}')
return False
# Create temporary files
py_file = self.create_python_file(kind)
spec_file = self.create_spec_file(kind, py_file)
# Build with PyInstaller
dist_path = self.dist_dir / kind
pdm_bin = os.getenv('PDM_BIN', 'pdm')
cmd = [
pdm_bin,
'run',
'pyinstaller',
'--noconfirm',
'--distpath',
str(dist_path.parent),
str(spec_file),
]
try:
result = subprocess.run(cmd, cwd=self.base_dir, capture_output=True, text=True)
if result.returncode == 0:
print(f'[OK] Built {kind}')
return True
else:
print(f'[FAIL] Failed to build {kind}')
print(f'Error: {result.stderr}')
return False
except Exception as e:
print(f'[ERROR] Exception building {kind}: {e}')
return False
def build_all_kinds(self) -> Dict[str, bool]:
"""Build all kind variants."""
results = {}
for kind in KINDS:
success = self.build_variant(kind)
results[kind] = success
return results
def main():
parser = argparse.ArgumentParser(description='Dynamic build system for nvda-voicemeeter')
parser.add_argument(
'kinds',
nargs='*',
choices=KINDS + ['all'],
help='Kinds to build (default: all)',
)
parser.add_argument(
'--dist-dir',
type=Path,
default=Path('dist'),
help='Distribution output directory',
)
args = parser.parse_args()
if not args.kinds or 'all' in args.kinds:
kinds_to_build = KINDS
else:
kinds_to_build = args.kinds
base_dir = Path.cwd()
args.dist_dir.mkdir(exist_ok=True)
print(f'Building kinds: {", ".join(kinds_to_build)}')
all_results = {}
with DynamicBuilder(base_dir, args.dist_dir) as builder:
if 'all' in kinds_to_build or len(kinds_to_build) == len(KINDS):
# Build all kinds
results = builder.build_all_kinds()
all_results.update(results)
else:
# Build specific kinds
for kind in kinds_to_build:
success = builder.build_variant(kind)
all_results[kind] = success
# Report results
print('\n' + '=' * 50)
print('BUILD SUMMARY')
print('=' * 50)
success_count = 0
total_count = 0
for build_name, success in all_results.items():
status = '[OK]' if success else '[FAIL]'
print(f'{status} {build_name}')
if success:
success_count += 1
total_count += 1
print(f'\nSuccess: {success_count}/{total_count}')
if success_count == total_count:
print('All builds completed successfully!')
sys.exit(0)
else:
print('Some builds failed!')
sys.exit(1)
if __name__ == '__main__':
main()