Compare commits

...

15 Commits

Author SHA1 Message Date
270bda2dc1 add release workflow 2026-03-10 02:21:07 +00:00
6d5bd673a4 add compress tasks to Taskfile.dynamic 2026-03-10 02:18:26 +00:00
16ac188eb4 add shebang + docstring 2026-03-10 01:31:58 +00:00
737dc75cba add dynamic_builder script
add Taskfile for running dynamic builds
2026-03-10 01:31:25 +00:00
03d8415f68 add spec_generator script
add generate-specs target to Taskfile.
add generate-specs target as build dep.
2026-03-10 01:27:04 +00:00
bd868d4613 add .gitkeep files to show the expected dir structure for spec and theme files. 2026-03-10 01:15:13 +00:00
a65f851403 upd lockfile 2026-03-09 22:58:13 +00:00
9e5b5a31a8 bump dep versions 2026-03-09 22:57:39 +00:00
7aa8091de6 fix bus mode/mono button widths.
upd busmono textvariable values
2026-03-09 22:40:34 +00:00
d903faecd9 ensure we get the right bus modes according to the kind 2026-03-09 22:19:10 +00:00
b0d7d734fb make rewrite/restore tasks public 2026-03-09 21:37:59 +00:00
262dcd572b no need to rewrite entry point anymore 2026-03-09 21:37:43 +00:00
d612d38933 add padding to bus mode/mono buttons 2026-03-09 21:34:42 +00:00
5a693b8aaf entry point now accepts an optional theme arg. this makes it easier to test forest/azure themes 2026-03-09 21:34:20 +00:00
9210a26de6 fix source path for theme files
don't create (invisible) info button if azure theme
2026-03-09 21:31:39 +00:00
21 changed files with 990 additions and 71 deletions

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

@ -0,0 +1,198 @@
name: Release Voicemeeter Compact
on:
release:
types: [published]
push:
tags: ['v*.*.*']
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install Task
run: |
Invoke-WebRequest -OutFile go-task.zip -Uri "https://github.com/go-task/task/releases/latest/download/task_windows_amd64.zip"
Expand-Archive -Path go-task.zip -DestinationPath .
Move-Item task.exe C:\Windows\System32\
shell: pwsh
- name: Download Forest TTK Theme
run: |
# Clone the Forest theme repository
git clone https://github.com/rdbende/Forest-ttk-theme.git temp-forest-theme
# Copy the required theme files to theme/forest
Copy-Item "temp-forest-theme\forest-dark.tcl" "theme\forest\"
Copy-Item "temp-forest-theme\forest-light.tcl" "theme\forest\"
Copy-Item "temp-forest-theme\forest-dark" "theme\forest\" -Recurse
Copy-Item "temp-forest-theme\forest-light" "theme\forest\" -Recurse
# Clean up
Remove-Item temp-forest-theme -Recurse -Force
shell: pwsh
- name: Download Azure TTK Theme
run: |
# Clone the Azure theme repository
git clone https://github.com/rdbende/Azure-ttk-theme.git temp-azure-theme
# Copy the required theme files to theme/azure
Copy-Item "temp-azure-theme\azure.tcl" "theme\azure\"
Copy-Item "temp-azure-theme\theme" "theme\azure\" -Recurse
# Clean up
Remove-Item temp-azure-theme -Recurse -Force
shell: pwsh
- name: Cache Poetry dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
- name: Install dependencies
run: poetry install --with build
- name: Build artifacts with dynamic taskfile
run: task --taskfile Taskfile.dynamic.yml build-all
- name: Create release archives
run: task --taskfile Taskfile.dynamic.yml compress-all
# Sunvalley theme variants
- name: Upload build artifacts - Sunvalley Basic
uses: actions/upload-artifact@v4
with:
name: sunvalley-basic
path: dist/sunvalley-basic.zip
- name: Upload build artifacts - Sunvalley Banana
uses: actions/upload-artifact@v4
with:
name: sunvalley-banana
path: dist/sunvalley-banana.zip
- name: Upload build artifacts - Sunvalley Potato
uses: actions/upload-artifact@v4
with:
name: sunvalley-potato
path: dist/sunvalley-potato.zip
# Forest theme variants (dark)
- name: Upload build artifacts - Forest Basic Dark
uses: actions/upload-artifact@v4
with:
name: forest-basic-dark
path: dist/forest-basic-dark.zip
- name: Upload build artifacts - Forest Banana Dark
uses: actions/upload-artifact@v4
with:
name: forest-banana-dark
path: dist/forest-banana-dark.zip
- name: Upload build artifacts - Forest Potato Dark
uses: actions/upload-artifact@v4
with:
name: forest-potato-dark
path: dist/forest-potato-dark.zip
# Forest theme variants (light)
- name: Upload build artifacts - Forest Basic Light
uses: actions/upload-artifact@v4
with:
name: forest-basic-light
path: dist/forest-basic-light.zip
- name: Upload build artifacts - Forest Banana Light
uses: actions/upload-artifact@v4
with:
name: forest-banana-light
path: dist/forest-banana-light.zip
- name: Upload build artifacts - Forest Potato Light
uses: actions/upload-artifact@v4
with:
name: forest-potato-light
path: dist/forest-potato-light.zip
# Azure theme variants (dark)
- name: Upload build artifacts - Azure Basic Dark
uses: actions/upload-artifact@v4
with:
name: azure-basic-dark
path: dist/azure-basic-dark.zip
- name: Upload build artifacts - Azure Banana Dark
uses: actions/upload-artifact@v4
with:
name: azure-banana-dark
path: dist/azure-banana-dark.zip
- name: Upload build artifacts - Azure Potato Dark
uses: actions/upload-artifact@v4
with:
name: azure-potato-dark
path: dist/azure-potato-dark.zip
# Azure theme variants (light)
- name: Upload build artifacts - Azure Basic Light
uses: actions/upload-artifact@v4
with:
name: azure-basic-light
path: dist/azure-basic-light.zip
- name: Upload build artifacts - Azure Banana Light
uses: actions/upload-artifact@v4
with:
name: azure-banana-light
path: dist/azure-banana-light.zip
- name: Upload build artifacts - Azure Potato Light
uses: actions/upload-artifact@v4
with:
name: azure-potato-light
path: dist/azure-potato-light.zip
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- 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 "Release $TAG_NAME" --generate-notes
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 }}

112
.gitignore vendored
View File

@ -1,9 +1,9 @@
# quick test # Generated by ignr: github.com/onyx-and-iris/ignr
quick.py
## Python ##
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[codz]
*$py.class *$py.class
# C extensions # C extensions
@ -23,7 +23,6 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
@ -50,9 +49,10 @@ htmlcov/
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py.cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/
# Translations # Translations
*.mo *.mo
@ -75,6 +75,7 @@ instance/
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
@ -85,7 +86,9 @@ profile_default/
ipython_config.py ipython_config.py
# pyenv # pyenv
.python-version # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@ -94,7 +97,37 @@ ipython_config.py
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow # UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
# Celery stuff # Celery stuff
@ -106,13 +139,13 @@ celerybeat.pid
# Environments # Environments
.env .env
.envrc
.venv .venv
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
venv_vmcompact/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
@ -132,8 +165,65 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# build # pytype static type analyzer
theme/ .pytype/
spec/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# 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/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
.vscode/ .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# End of ignr
# Test files
test-*.py
# Forest/Azure theme files
theme/**/*
!theme/*/
!theme/**/.gitkeep
# Spec files
spec/**/*
!spec/*/
!spec/**/.gitkeep
# Taskfile build files
Taskfile.unified.yml
SPEC_CONSOLIDATION.md

View File

@ -13,13 +13,11 @@ tasks:
cmd: poetry run pyinstaller --noconfirm --distpath dist/{{.ITEM.THEME}}-{{.ITEM.KIND}} spec/azure/{{.ITEM.THEME}}-{{.ITEM.KIND}}.spec cmd: poetry run pyinstaller --noconfirm --distpath dist/{{.ITEM.THEME}}-{{.ITEM.KIND}} spec/azure/{{.ITEM.THEME}}-{{.ITEM.KIND}}.spec
rewrite: rewrite:
internal: true
desc: Run the source code rewriter desc: Run the source code rewriter
cmds: cmds:
- poetry run python tools/rewriter.py --rewrite --theme {{.THEME}} - poetry run python tools/rewriter.py --rewrite --theme {{.THEME}}
restore: restore:
internal: true
desc: Restore the backup files desc: Restore the backup files
cmds: cmds:
- poetry run python tools/rewriter.py --restore - poetry run python tools/rewriter.py --restore

76
Taskfile.dynamic.yml Normal file
View File

@ -0,0 +1,76 @@
version: '3'
# Dynamic build system - no spec files needed!
# Usage: task build THEMES="azure forest" or task build-all
vars:
THEMES: '{{.THEMES | default "all"}}'
SHELL: pwsh
tasks:
build:
desc: Build specified themes dynamically (no spec files needed)
cmds:
- poetry run python tools/dynamic_builder.py {{.THEMES}}
build-all:
desc: Build all themes
cmds:
- poetry run python tools/dynamic_builder.py all
build-azure:
desc: Build only azure theme
cmds:
- poetry run python tools/dynamic_builder.py azure
build-forest:
desc: Build only forest theme
cmds:
- poetry run python tools/dynamic_builder.py forest
build-sunvalley:
desc: Build only sunvalley theme
cmds:
- poetry run python tools/dynamic_builder.py sunvalley
compress:
desc: Compress artifacts for specified theme
cmds:
- task: compress-{{.THEME}}
compress-all:
desc: Compress artifacts for all themes
cmds:
- for:
matrix:
THEME: [azure, forest, sunvalley]
task: compress-{{.ITEM.THEME}}
compress-azure:
cmds:
- for:
matrix:
KIND: [basic, banana, potato]
VARIANT: [azure-light, azure-dark]
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/{{.ITEM.VARIANT}}-{{.ITEM.KIND}} -DestinationPath dist/{{.ITEM.VARIANT}}-{{.ITEM.KIND}}.zip -Force"'
compress-forest:
cmds:
- for:
matrix:
KIND: [basic, banana, potato]
VARIANT: [forest-light, forest-dark]
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/{{.ITEM.VARIANT}}-{{.ITEM.KIND}} -DestinationPath dist/{{.ITEM.VARIANT}}-{{.ITEM.KIND}}.zip -Force"'
compress-sunvalley:
cmds:
- for:
matrix:
KIND: [basic, banana, potato]
cmd: '{{.SHELL}} -Command "Compress-Archive -Path dist/sunvalley-{{.ITEM.KIND}} -DestinationPath dist/sunvalley-{{.ITEM.KIND}}.zip -Force"'
clean:
desc: Clean all build artifacts
cmds:
- |
{{.SHELL}} -Command "Remove-Item -Path build/*,dist/* -Recurse -Force -ErrorAction SilentlyContinue"

View File

@ -13,13 +13,11 @@ tasks:
cmd: poetry run pyinstaller --noconfirm --distpath dist/{{.ITEM.THEME}}-{{.ITEM.KIND}} spec/forest/{{.ITEM.THEME}}-{{.ITEM.KIND}}.spec cmd: poetry run pyinstaller --noconfirm --distpath dist/{{.ITEM.THEME}}-{{.ITEM.KIND}} spec/forest/{{.ITEM.THEME}}-{{.ITEM.KIND}}.spec
rewrite: rewrite:
internal: true
desc: Run the source code rewriter desc: Run the source code rewriter
cmds: cmds:
- poetry run python tools/rewriter.py --rewrite --theme {{.THEME}} - poetry run python tools/rewriter.py --rewrite --theme {{.THEME}}
restore: restore:
internal: true
desc: Restore the backup files desc: Restore the backup files
cmds: cmds:
- poetry run python tools/rewriter.py --restore - poetry run python tools/rewriter.py --restore

View File

@ -30,8 +30,14 @@ tasks:
- task: compress - task: compress
- echo "Release complete" - echo "Release complete"
generate-specs:
desc: Generate all spec files from templates
cmds:
- poetry run python tools/spec_generator.py --clean
build: build:
desc: Build all artifacts desc: Build all artifacts
deps: [generate-specs]
cmds: cmds:
- for: - for:
matrix: matrix:

4
poetry.lock generated
View File

@ -223,7 +223,7 @@ files = [
[[package]] [[package]]
name = "vban-cmd" name = "vban-cmd"
version = "2.10.1" version = "2.10.2"
description = "Python interface for the VBAN RT Packet Service (Sendtext)" description = "Python interface for the VBAN RT Packet Service (Sendtext)"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
@ -258,4 +258,4 @@ url = "../voicemeeter-api-python"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "171da15ce55f47b4e651aade40ab21afd5ef589ff7ff26e51caf6840d25b98a1" content-hash = "f1e1782280c5e165fef043ca2695ea5f5c93fd00a66ace809266e0196fef6b71"

View File

@ -8,7 +8,7 @@ readme = "README.md"
requires-python = ">=3.10,<3.14" requires-python = ">=3.10,<3.14"
dependencies = [ dependencies = [
"voicemeeter-api (>=2.7.2,<3.0.0)", "voicemeeter-api (>=2.7.2,<3.0.0)",
"vban-cmd (>=2.10.1,<3.0.0)", "vban-cmd (>=2.10.2,<3.0.0)",
"sv-ttk (>=2.6.0,<3.0.0)", "sv-ttk (>=2.6.0,<3.0.0)",
"tomli (>=2.0.1,<3.0) ; python_version < '3.11'", "tomli (>=2.0.1,<3.0) ; python_version < '3.11'",
] ]

0
spec/azure/.gitkeep Normal file
View File

0
spec/forest/.gitkeep Normal file
View File

0
spec/sunvalley/.gitkeep Normal file
View File

0
theme/azure/.gitkeep Normal file
View File

0
theme/forest/.gitkeep Normal file
View File

318
tools/dynamic_builder.py Normal file
View File

@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
Dynamic build system for voicemeeter-compact.
Generates spec files on-the-fly and builds executables without storing intermediate files.
"""
import argparse
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Dict
# Build configuration
THEMES = {
'azure': ['azure-light', 'azure-dark'],
'forest': ['forest-light', 'forest-dark'],
'sunvalley': ['sunvalley'],
}
KINDS = ['basic', 'banana', 'potato']
# Templates (same as spec_generator.py)
PYTHON_TEMPLATE = """import voicemeeterlib
import vmcompact
def main():
KIND_ID = '{kind}'
with voicemeeterlib.api(KIND_ID) as vmr:{theme_arg}
app = vmcompact.connect(KIND_ID, vmr{theme_param})
app.mainloop()
if __name__ == '__main__':
main()
"""
SPEC_TEMPLATE = """# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
added_files = [
( '{img_path}', 'img' ),{theme_files}
( '{config_path}', 'configs' ),
]
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='{theme_variant}-{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='{theme_variant}-{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 __enter__(self):
self.temp_dir = Path(tempfile.mkdtemp(prefix='vmcompact_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, theme_variant: str, kind: str) -> Path:
"""Create a temporary Python launcher file."""
if theme_variant == 'sunvalley':
theme_arg = ''
theme_param = ''
else:
theme_arg = f"\n theme = '{theme_variant}'"
theme_param = ', theme=theme'
content = PYTHON_TEMPLATE.format(
kind=kind, theme_arg=theme_arg, theme_param=theme_param
)
py_file = self.temp_dir / f'{theme_variant}-{kind}.py'
with open(py_file, 'w') as f:
f.write(content)
return py_file
def create_spec_file(self, theme_variant: str, kind: str, py_file: Path) -> Path:
"""Create a temporary PyInstaller spec file."""
if theme_variant == 'sunvalley':
theme_files = ''
else:
theme_base = theme_variant.split('-')[0]
theme_path = (self.base_dir / 'theme' / theme_base).as_posix()
theme_files = f"\n ( '{theme_path}', 'theme' ),"
content = SPEC_TEMPLATE.format(
script_path=py_file.as_posix(),
img_path=(self.base_dir / 'vmcompact' / 'img').as_posix(),
config_path=(self.base_dir / 'configs').as_posix(),
theme_files=theme_files,
kind=kind,
theme_variant=theme_variant,
)
spec_file = self.temp_dir / f'{theme_variant}-{kind}.spec'
with open(spec_file, 'w') as f:
f.write(content)
return spec_file
def build_variant(self, theme_variant: str, kind: str) -> bool:
"""Build a single theme/kind variant."""
print(f'Building {theme_variant}-{kind}...')
# Create temporary files
py_file = self.create_python_file(theme_variant, kind)
spec_file = self.create_spec_file(theme_variant, kind, py_file)
# Build with PyInstaller
dist_path = self.dist_dir / f'{theme_variant}-{kind}'
cmd = [
'poetry',
'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'✓ Built {theme_variant}-{kind}')
return True
else:
print(f'✗ Failed to build {theme_variant}-{kind}')
print(f'Error: {result.stderr}')
return False
except Exception as e:
print(f'✗ Exception building {theme_variant}-{kind}: {e}')
return False
def build_theme(self, theme_family: str) -> Dict[str, bool]:
"""Build all variants for a theme family."""
results = {}
if theme_family not in THEMES:
print(f'Unknown theme: {theme_family}')
return results
variants = THEMES[theme_family]
for variant in variants:
for kind in KINDS:
success = self.build_variant(variant, kind)
results[f'{variant}-{kind}'] = success
return results
def run_rewriter(theme_family: str, base_dir: Path) -> bool:
"""Run the theme rewriter if needed."""
if theme_family in ['azure', 'forest']:
print(f'Running rewriter for {theme_family} theme...')
cmd = [
'poetry',
'run',
'python',
'tools/rewriter.py',
'--rewrite',
'--theme',
theme_family,
]
try:
result = subprocess.run(cmd, cwd=base_dir)
return result.returncode == 0
except Exception as e:
print(f'Rewriter failed: {e}')
return False
return True
def restore_rewriter(base_dir: Path) -> bool:
"""Restore files after rewriter."""
print('Restoring rewriter changes...')
cmd = ['poetry', 'run', 'python', 'tools/rewriter.py', '--restore']
try:
result = subprocess.run(cmd, cwd=base_dir)
return result.returncode == 0
except Exception as e:
print(f'Restore failed: {e}')
return False
def main():
parser = argparse.ArgumentParser(
description='Dynamic build system for voicemeeter-compact'
)
parser.add_argument(
'themes',
nargs='*',
choices=list(THEMES.keys()) + ['all'],
help='Themes 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.themes or 'all' in args.themes:
themes_to_build = list(THEMES.keys())
else:
themes_to_build = args.themes
base_dir = Path.cwd()
args.dist_dir.mkdir(exist_ok=True)
print(f'Building themes: {", ".join(themes_to_build)}')
all_results = {}
with DynamicBuilder(base_dir, args.dist_dir) as builder:
for theme_family in themes_to_build:
# Run rewriter if needed
if not run_rewriter(theme_family, base_dir):
print(f'Skipping {theme_family} due to rewriter failure')
continue
try:
# Build theme
results = builder.build_theme(theme_family)
all_results.update(results)
finally:
# Always restore rewriter changes
if theme_family in ['azure', 'forest']:
restore_rewriter(base_dir)
# 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 = '' if success else ''
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()

View File

@ -1,3 +1,8 @@
#!/usr/bin/env python3
"""Rewrites app.py, builders.py, menu.py, and navigation.py to remove sv_ttk dependencies and apply theme changes for the build process.
Also provides a cleanup function to restore the original files after building.
"""
import argparse import argparse
import logging import logging
from pathlib import Path from pathlib import Path
@ -25,9 +30,6 @@ def rewrite_app(theme):
with open(outfile, 'w') as output: with open(outfile, 'w') as output:
for line in input: for line in input:
match line: match line:
# App init()
case ' def __init__(self, vmr):\n':
output.write(' def __init__(self, vmr, theme):\n')
case ' self._vmr = vmr\n': case ' self._vmr = vmr\n':
write_outs( write_outs(
output, output,
@ -36,7 +38,7 @@ def rewrite_app(theme):
' self._theme = theme\n', ' self._theme = theme\n',
' self._theme_name = theme.split("-")[0]\n', ' self._theme_name = theme.split("-")[0]\n',
' self._theme_type = theme.split("-")[-1]\n', ' self._theme_type = theme.split("-")[-1]\n',
' tcldir = Path.cwd() / "theme"\n', ' tcldir = Path.cwd() / "theme" / self._theme_name\n',
' if not tcldir.is_dir():\n', ' if not tcldir.is_dir():\n',
' tcldir = Path.cwd() / "_internal" / "theme"\n', ' tcldir = Path.cwd() / "_internal" / "theme"\n',
' match self._theme_name:\n', ' match self._theme_name:\n',
@ -46,11 +48,6 @@ def rewrite_app(theme):
' self.tk.call("source", tcldir.resolve() / f"{self._theme_name}.tcl")\n', ' self.tk.call("source", tcldir.resolve() / f"{self._theme_name}.tcl")\n',
), ),
) )
# def connect()
case 'def connect(kind_id: str, vmr) -> App:\n':
output.write('def connect(kind_id: str, vmr, theme) -> App:\n')
case ' return VMMIN_cls(vmr)\n':
output.write(' return VMMIN_cls(vmr, theme)\n')
case _: case _:
output.write(line) output.write(line)
@ -223,37 +220,54 @@ def rewrite_menu(theme):
output.write(line) output.write(line)
def rewrite_navigation(theme):
navigation_logger = logger.getChild('navigation')
navigation_logger.info('rewriting navigation.py')
infile = Path(SRC_DIR) / 'navigation.bk'
outfile = Path(PACKAGE_DIR) / 'navigation.py'
with open(infile, 'r') as input:
with open(outfile, 'w') as output:
for line in input:
match line:
case ' self.builder.create_info_button()\n':
if theme.startswith('azure'):
output.write(
' # self.builder.create_info_button()\n'
)
else:
output.write(line)
case _:
output.write(line)
def prepare_for_build(theme): def prepare_for_build(theme):
################# MOVE FILES FROM PACKAGE DIR INTO SRC DIR ######################### ################# MOVE FILES FROM PACKAGE DIR INTO SRC DIR #########################
for file in ( for file in (
PACKAGE_DIR / 'app.py', PACKAGE_DIR / 'app.py',
PACKAGE_DIR / 'builders.py', PACKAGE_DIR / 'builders.py',
PACKAGE_DIR / 'menu.py', PACKAGE_DIR / 'menu.py',
PACKAGE_DIR / 'navigation.py',
): ):
if file.exists(): if file.exists():
logger.debug(f'moving {str(file)}') logger.debug(f'moving {str(file)}')
file.rename(SRC_DIR / f'{file.stem}.bk') file.rename(SRC_DIR / f'{file.stem}.bk')
###################### RUN THE FILE REWRITER FOR EACH *.BK ######################### ###################### RUN THE FILE REWRITER FOR EACH *.BK #########################
for step in (rewrite_app, rewrite_builders, rewrite_menu): for step in (rewrite_app, rewrite_builders, rewrite_menu, rewrite_navigation):
step(theme) step(theme)
def cleanup(): def cleanup():
########################## RESTORE *.BK FILES ##################################### ########################## RESTORE *.BK FILES #####################################
for file in (
PACKAGE_DIR / 'app.py',
PACKAGE_DIR / 'builders.py',
PACKAGE_DIR / 'menu.py',
):
file.unlink()
for file in ( for file in (
SRC_DIR / 'app.bk', SRC_DIR / 'app.bk',
SRC_DIR / 'builders.bk', SRC_DIR / 'builders.bk',
SRC_DIR / 'menu.bk', SRC_DIR / 'menu.bk',
SRC_DIR / 'navigation.bk',
): ):
file.rename(PACKAGE_DIR / f'{file.stem}.py') if file.exists():
logger.debug(f'moving {str(file)}')
file.replace(PACKAGE_DIR / f'{file.stem}.py')
if __name__ == '__main__': if __name__ == '__main__':

192
tools/spec_generator.py Normal file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Spec file generator for voicemeeter-compact builds.
Generates Python launcher files and PyInstaller spec files from templates.
"""
import argparse
from pathlib import Path
# Build configuration
THEMES = {
'azure': ['azure-light', 'azure-dark'],
'forest': ['forest-light', 'forest-dark'],
'sunvalley': ['sunvalley'], # Single variant, no light/dark
}
KINDS = ['basic', 'banana', 'potato']
# Templates
PYTHON_TEMPLATE = """import voicemeeterlib
import vmcompact
def main():
KIND_ID = '{kind}'
with voicemeeterlib.api(KIND_ID) as vmr:{theme_arg}
app = vmcompact.connect(KIND_ID, vmr{theme_param})
app.mainloop()
if __name__ == '__main__':
main()
"""
SPEC_TEMPLATE = """# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
added_files = [
( '../../vmcompact/img', 'img' ),{theme_files}
( '../../configs', 'configs' ),
]
a = Analysis(
['{script_name}'],
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}',
)
"""
def generate_python_file(theme_variant: str, kind: str, output_dir: Path) -> None:
"""Generate a Python launcher file."""
if theme_variant == 'sunvalley':
# Sunvalley doesn't use theme parameter
theme_arg = ''
theme_param = ''
else:
theme_arg = f"\n theme = '{theme_variant}'"
theme_param = ', theme=theme'
content = PYTHON_TEMPLATE.format(
kind=kind, theme_arg=theme_arg, theme_param=theme_param
)
filename = f'{theme_variant}-{kind}.py'
output_path = output_dir / filename
with open(output_path, 'w') as f:
f.write(content)
print(f'Generated: {output_path}')
def generate_spec_file(theme_variant: str, kind: str, output_dir: Path) -> None:
"""Generate a PyInstaller spec file."""
script_name = f'{theme_variant}-{kind}.py'
if theme_variant == 'sunvalley':
# Sunvalley doesn't include theme files
theme_files = ''
else:
theme_base = theme_variant.split('-')[0] # 'azure' from 'azure-dark'
theme_files = f"\n ( '../../theme/{theme_base}', 'theme' ),"
content = SPEC_TEMPLATE.format(
script_name=script_name, theme_files=theme_files, kind=kind
)
filename = f'{theme_variant}-{kind}.spec'
output_path = output_dir / filename
with open(output_path, 'w') as f:
f.write(content)
print(f'Generated: {output_path}')
def generate_all_files(output_base_dir: Path) -> None:
"""Generate all Python and spec files for all theme/kind combinations."""
for theme_family, theme_variants in THEMES.items():
theme_dir = output_base_dir / theme_family
theme_dir.mkdir(parents=True, exist_ok=True)
for theme_variant in theme_variants:
for kind in KINDS:
generate_python_file(theme_variant, kind, theme_dir)
generate_spec_file(theme_variant, kind, theme_dir)
def clean_existing_files(output_base_dir: Path) -> None:
"""Remove all existing generated files."""
for theme_family in THEMES.keys():
theme_dir = output_base_dir / theme_family
if theme_dir.exists():
for file in theme_dir.glob('*.py'):
file.unlink()
print(f'Removed: {file}')
for file in theme_dir.glob('*.spec'):
file.unlink()
print(f'Removed: {file}')
def main():
parser = argparse.ArgumentParser(
description='Generate spec files for voicemeeter-compact'
)
parser.add_argument(
'--clean', action='store_true', help='Clean existing files before generating'
)
parser.add_argument(
'--output-dir',
type=Path,
default=Path('spec'),
help='Output directory for spec files (default: spec)',
)
args = parser.parse_args()
if args.clean:
print('Cleaning existing files...')
clean_existing_files(args.output_dir)
print('Generating spec files...')
generate_all_files(args.output_dir)
print('Done!')
if __name__ == '__main__':
main()

View File

@ -6,10 +6,11 @@ from tkinter import messagebox, ttk
from typing import NamedTuple from typing import NamedTuple
import voicemeeterlib import voicemeeterlib
from voicemeeterlib import kinds
from .builders import MainFrameBuilder from .builders import MainFrameBuilder
from .configurations import loader from .configurations import loader
from .data import _base_values, _configuration, _kinds_all, get_configuration from .data import _base_values, _configuration, get_configuration
from .errors import VMCompactError from .errors import VMCompactError
from .menu import Menus from .menu import Menus
from .subject import Subject from .subject import Subject
@ -37,7 +38,7 @@ class App(tk.Tk):
) )
return APP_cls return APP_cls
def __init__(self, vmr): def __init__(self, vmr, theme):
super().__init__() super().__init__()
self.logger = logger.getChild(self.__class__.__name__) self.logger = logger.getChild(self.__class__.__name__)
self._vmr = vmr self._vmr = vmr
@ -198,14 +199,14 @@ class App(tk.Tk):
self.destroy() self.destroy()
_apps = {kind.name: App.make(kind) for kind in _kinds_all} _apps = {kind.name: App.make(kind) for kind in kinds.all}
def connect(kind_id: str, vmr) -> App: def connect(kind_id: str, vmr, theme=None) -> App:
"""return App of the kind requested""" """return App of the kind requested"""
try: try:
VMMIN_cls = _apps[kind_id] VMMIN_cls = _apps[kind_id]
except KeyError: except KeyError:
raise VMCompactError(f'Invalid kind: {kind_id}') raise VMCompactError(f'Invalid kind: {kind_id}')
return VMMIN_cls(vmr) return VMMIN_cls(vmr, theme)

View File

@ -6,6 +6,7 @@ from tkinter import ttk
import sv_ttk import sv_ttk
from . import util
from .banner import Banner from .banner import Banner
from .channels import _make_channelframe from .channels import _make_channelframe
from .config import BusConfig, StripConfig from .config import BusConfig, StripConfig
@ -561,31 +562,22 @@ class StripConfigFrameBuilder(ChannelConfigFrameBuilder):
class BusConfigFrameBuilder(ChannelConfigFrameBuilder): class BusConfigFrameBuilder(ChannelConfigFrameBuilder):
"""Responsible for building channel configframe widgets""" """Responsible for building channel configframe widgets"""
def __init__(self, configframe, app):
super().__init__(configframe)
self.app = app
def setup(self): def setup(self):
# fmt: off self.configframe.bus_mode_map = util.get_busmode_fullnames(self.app.kind)
self.configframe.bus_mode_map = { self.configframe.bus_mode_map_reverse = util.get_busmode_fullnames_reversed(
"normal": "Normal", self.app.kind
"amix": "Mix Down A", )
"bmix": "Mix Down B", self.configframe.bus_modes = util.get_busmode_shortnames(self.app.kind)
"repeat": "Stereo Repeat",
"composite": "Composite",
"tvmix": "Up Mix TV",
"upmix21": "Up Mix 2.1",
"upmix41": "Up Mix 4.1",
"upmix61": "Up Mix 6.1",
"centeronly": "Center Only",
"lfeonly": "LFE Only",
"rearonly": "Rear Only",
}
self.configframe.bus_mode_map_reverse = {v: k for k, v in self.configframe.bus_mode_map.items()}
self.configframe.bus_modes = list(self.configframe.bus_mode_map.keys())
# fmt: on
self.configframe.int_params = ('mono',) self.configframe.int_params = ('mono',)
self.configframe.int_param_vars = [ self.configframe.int_param_vars = [
tk.IntVar(value=getattr(self.configframe.target, param)) tk.IntVar(value=getattr(self.configframe.target, param))
for param in self.configframe.int_params for param in self.configframe.int_params
] ]
self.configframe.mono_modes = ['mono: off', 'mono: on', 'stereo reverse'] self.configframe.mono_modes = util.get_busmono_modes()
self.configframe.bus_mono_label_text = tk.StringVar( self.configframe.bus_mono_label_text = tk.StringVar(
value=self.configframe.mono_modes[self.configframe.target.mono] value=self.configframe.mono_modes[self.configframe.target.mono]
) )
@ -599,10 +591,12 @@ class BusConfigFrameBuilder(ChannelConfigFrameBuilder):
def create_bus_mode_button(self): def create_bus_mode_button(self):
self.configframe.busmode_button = ttk.Button( self.configframe.busmode_button = ttk.Button(
self.configframe, textvariable=self.configframe.bus_mode_label_text self.configframe,
textvariable=self.configframe.bus_mode_label_text,
width=15,
) )
self.configframe.busmode_button.grid( self.configframe.busmode_button.grid(
column=0, row=0, columnspan=2, sticky=(tk.W) column=0, row=0, columnspan=2, sticky=(tk.W), padx=1, pady=1
) )
self.configframe.busmode_button.bind( self.configframe.busmode_button.bind(
'<Button-1>', '<Button-1>',
@ -619,7 +613,9 @@ class BusConfigFrameBuilder(ChannelConfigFrameBuilder):
def create_bus_mono_button(self): def create_bus_mono_button(self):
self.configframe.mono_button = ttk.Button( self.configframe.mono_button = ttk.Button(
self.configframe, textvariable=self.configframe.bus_mono_label_text self.configframe,
textvariable=self.configframe.bus_mono_label_text,
width=15,
) )
self.configframe.mono_button.bind( self.configframe.mono_button.bind(
'<Button-1>', '<Button-1>',
@ -629,7 +625,9 @@ class BusConfigFrameBuilder(ChannelConfigFrameBuilder):
'<Button-3>', '<Button-3>',
partial(self.configframe.pause_updates, self.configframe.rotate_mono_left), partial(self.configframe.pause_updates, self.configframe.rotate_mono_left),
) )
self.configframe.mono_button.grid(column=0, row=1, sticky=(tk.W)) self.configframe.mono_button.grid(
column=0, row=1, sticky=(tk.W), padx=1, pady=1
)
def create_param_buttons(self): def create_param_buttons(self):
param_buttons = [ param_buttons = [

View File

@ -217,7 +217,7 @@ class BusConfig(Config):
self.grid(column=0, row=1, columnspan=4, padx=(2,)) self.grid(column=0, row=1, columnspan=4, padx=(2,))
else: else:
self.grid(column=0, row=3, columnspan=4, padx=(2,)) self.grid(column=0, row=3, columnspan=4, padx=(2,))
self.builder = builders.BusConfigFrameBuilder(self) self.builder = builders.BusConfigFrameBuilder(self, parent)
self.builder.setup() self.builder.setup()
self.make_row_0() self.make_row_0()
self.make_row_1() self.make_row_1()

View File

@ -61,10 +61,6 @@ class BaseValues(metaclass=SingletonMeta):
_base_values = BaseValues() _base_values = BaseValues()
_configuration = Configurations() _configuration = Configurations()
_kinds = {kind.name: kind for kind in kinds.kinds_all}
_kinds_all = _kinds.values()
def kind_get(kind_id): def kind_get(kind_id):
return _kinds[kind_id] return kinds.request_kind_map(kind_id)

34
vmcompact/util.py Normal file
View File

@ -0,0 +1,34 @@
def get_busmode_fullnames(kind) -> dict:
if kind.name == 'basic':
return {
'normal': 'Normal',
'amix': 'Mix Down A',
'repeat': 'Stereo Repeat',
'composite': 'Composite',
}
return {
'normal': 'Normal',
'amix': 'Mix Down A',
'bmix': 'Mix Down B',
'repeat': 'Stereo Repeat',
'composite': 'Composite',
'tvmix': 'Up Mix TV',
'upmix21': 'Up Mix 2.1',
'upmix41': 'Up Mix 4.1',
'upmix61': 'Up Mix 6.1',
'centeronly': 'Center Only',
'lfeonly': 'LFE Only',
'rearonly': 'Rear Only',
}
def get_busmode_fullnames_reversed(kind) -> dict:
return {v: k for k, v in get_busmode_fullnames(kind).items()}
def get_busmode_shortnames(kind) -> list:
return list(get_busmode_fullnames(kind).keys())
def get_busmono_modes() -> list:
return ['Mono: off', 'Mono: on', 'Stereo Reverse']