Compare commits

..

12 Commits

Author SHA1 Message Date
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
8 changed files with 674 additions and 8 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'

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

@ -1,6 +1,6 @@
[project]
name = "nvda-voicemeeter"
version = "1.0.0"
version = "1.1.0"
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

@ -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

@ -58,6 +58,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()
@ -251,13 +252,16 @@ class NVDAVMWindow(psg.Window):
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)
@ -306,7 +310,7 @@ class NVDAVMWindow(psg.Window):
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:
@ -972,7 +976,7 @@ class NVDAVMWindow(psg.Window):
self.nvda.speak,
'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
@ -981,6 +985,15 @@ class NVDAVMWindow(psg.Window):
self.nvda.speak,
'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,
f'mono {chosen}',
)
case 'MODE':
chosen = util._bus_mode_map_reversed[values[event]]
setattr(self.vm.bus[int(index)].mode, chosen, True)
@ -996,11 +1009,19 @@ class NVDAVMWindow(psg.Window):
val = self.cache['bus'][f'BUS {index}||{param}']
if param == 'MODE':
self.nvda.speak(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(f'{label} {param} {busmode}')
else:
self.nvda.speak(f'{label} {busmode}')
else:
self.nvda.speak(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()

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()