From a040396b4def0f3814e72b5178cdecd7796c0e2e Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Tue, 19 May 2026 10:09:54 -0500
Subject: [PATCH 01/26] Improve contrast for small text in textgrid viewer
(#139)
---
src/voxkit/gui/pages/pipeline/viewer_stacker.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/voxkit/gui/pages/pipeline/viewer_stacker.py b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
index 2f8078a..95ecfb8 100644
--- a/src/voxkit/gui/pages/pipeline/viewer_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
@@ -359,7 +359,7 @@ def paintEvent(self, _event):
# Label inside block
if bw > 10 and iv_label:
- text_color = QColor("white") if (active or not silent) else color.darker(140)
+ text_color = QColor("white") if active else color.darker(140)
painter.setPen(text_color)
painter.drawText(
x1 + 2,
From fa339d8599031960dc55106689721ae133ebd51d Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Tue, 19 May 2026 10:10:54 -0500
Subject: [PATCH 02/26] Refactor: hoist dataset data-path helper to
storage.datasets (#140)
Extract get_dataset_data_path(meta) into voxkit.storage.datasets and
use it from viewer_stacker, training_stacker, and pllr_stacker. The
"cached -> root/cache, else original_path" rule now lives in one
place next to _get_dataset_root.
Side-effect fixes:
- training_stacker previously passed the dataset root (not root/cache)
as audio_path for cached datasets, inconsistent with the viewer and
pllr paths. It now resolves to root/cache like the others.
- pllr_stacker dropped the brittle string "True"/bool dual-check on
meta["cached"], matching the typed DatasetMetadata schema.
---
src/voxkit/gui/pages/pipeline/pllr_stacker.py | 9 +--------
src/voxkit/gui/pages/pipeline/training_stacker.py | 5 +----
src/voxkit/gui/pages/pipeline/viewer_stacker.py | 12 +-----------
src/voxkit/storage/datasets.py | 14 ++++++++++++++
4 files changed, 17 insertions(+), 23 deletions(-)
diff --git a/src/voxkit/gui/pages/pipeline/pllr_stacker.py b/src/voxkit/gui/pages/pipeline/pllr_stacker.py
index 65c0839..0714435 100644
--- a/src/voxkit/gui/pages/pipeline/pllr_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/pllr_stacker.py
@@ -386,14 +386,7 @@ def on_extract_pllr(self):
QMessageBox.warning(self, "Invalid Dataset", "Could not find dataset metadata.")
return
- wavlab_path: Path | str | None = None
- if not (dataset_meta["cached"] == "True" or dataset_meta["cached"] is True):
- wavlab_path = dataset_meta["original_path"]
-
- else:
- dataset_root = datasets._get_dataset_root(selected_dataset_id)
- if dataset_root:
- wavlab_path = dataset_root / "cache"
+ wavlab_path: Path | str | None = datasets.get_dataset_data_path(dataset_meta)
print(f"[DEBUG] Dataset root path: {wavlab_path}")
diff --git a/src/voxkit/gui/pages/pipeline/training_stacker.py b/src/voxkit/gui/pages/pipeline/training_stacker.py
index a4817d9..2be1070 100644
--- a/src/voxkit/gui/pages/pipeline/training_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/training_stacker.py
@@ -143,10 +143,7 @@ def on_train_model(self):
)
return
- if bool(dataset_metadata["cached"]):
- audio_path = datasets._get_dataset_root(selected_dataset_id)
- else:
- audio_path = Path(dataset_metadata["original_path"])
+ audio_path = datasets.get_dataset_data_path(dataset_metadata)
if not audio_path or not Path(audio_path).exists():
QMessageBox.warning(
diff --git a/src/voxkit/gui/pages/pipeline/viewer_stacker.py b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
index 95ecfb8..293b7c9 100644
--- a/src/voxkit/gui/pages/pipeline/viewer_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
@@ -40,7 +40,6 @@
from voxkit.gui.pages.pipeline.base_stacker import BaseStacker
from voxkit.gui.styles import Buttons, Colors, Containers, Labels
from voxkit.storage import alignments, datasets
-from voxkit.storage.datasets import _get_dataset_root
if TYPE_CHECKING:
from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer
@@ -118,15 +117,6 @@ def _parse_textgrid(filepath: str) -> list[dict]:
# ---------------------------------------------------------------------------
-def _dataset_data_path(meta: datasets.DatasetMetadata) -> Path:
- """Return the directory containing speaker subdirs (audio + .lab files)."""
- if meta.get("cached"):
- root = _get_dataset_root(meta["id"])
- if root:
- return root / "cache"
- return Path(meta["original_path"])
-
-
def _find_textgrid(tg_root: Path, speaker: str, stem: str) -> Path | None:
"""Probe common TextGrid layouts and return the first match."""
candidates = [
@@ -694,7 +684,7 @@ def _on_dataset_changed(self):
if not self._current_dataset_meta:
return
- self._current_data_path = _dataset_data_path(self._current_dataset_meta)
+ self._current_data_path = datasets.get_dataset_data_path(self._current_dataset_meta)
al_list = alignments.list_alignments(dataset_id)
if al_list:
diff --git a/src/voxkit/storage/datasets.py b/src/voxkit/storage/datasets.py
index 6180cda..2017112 100644
--- a/src/voxkit/storage/datasets.py
+++ b/src/voxkit/storage/datasets.py
@@ -101,6 +101,20 @@ def _get_dataset_root(dataset_id: str) -> Path | None:
return None
+def get_dataset_data_path(meta: DatasetMetadata) -> Path | None:
+ """Return the directory containing the dataset's speaker subdirs.
+
+ For cached datasets this is ``/cache``; for non-cached
+ datasets it is the original on-disk path recorded in metadata.
+ """
+ if meta.get("cached"):
+ root = _get_dataset_root(meta["id"])
+ if root is None:
+ return None
+ return root / "cache"
+ return Path(meta["original_path"])
+
+
def _get_dataset_metadata(dataset_root: Path) -> DatasetMetadata | None:
"""Load dataset metadata from the given dataset root directory.
From 58afe37f0043111bfadc7eceece5871ded244a69 Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Tue, 19 May 2026 10:25:38 -0500
Subject: [PATCH 03/26] Optimize workflows for speedup (#138)
* Collapse code quality workflow to one job/VM
* Rename job to specify sub component of code quality
* Install uv with setup naturally instead
* Remove python installation since uv handles python installation
* Remove soft code quality assurance in ubuntu test workflow
* Enable caching for uv across jobs
* Switchng to develop instead of release
* Add testing jobs in working branch develop
---
.github/workflows/code-quality.yml | 75 ++++-------------------------
.github/workflows/sync-docs.yml | 11 ++---
.github/workflows/tests-macos.yml | 9 ++--
.github/workflows/tests-ubuntu.yml | 19 ++------
.github/workflows/tests-windows.yml | 9 ++--
5 files changed, 25 insertions(+), 98 deletions(-)
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 5d0d2f3..7e0c5de 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -4,34 +4,28 @@ on:
push:
branches:
- main
- - release
+ - develop
pull_request:
branches:
- main
- - release
+ - develop
permissions:
contents: read
jobs:
- formatting:
- name: Code Formatting
+ code-quality:
+ name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- name: Install uv
- run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- shell: bash
+ uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
- name: Configure Git for private repos
run: |
@@ -41,62 +35,13 @@ jobs:
run: uv sync
- name: Check code formatting
+ if: always()
run: uv run invoke format-check
- linting:
- name: Linting
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install uv
- run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- shell: bash
-
- - name: Configure Git for private repos
- run: |
- git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
- - name: Install dependencies
- run: uv sync
-
- name: Check linting
+ if: always()
run: uv run invoke lint-check
- type-checking:
- name: Type Checking
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install uv
- run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- shell: bash
-
- - name: Configure Git for private repos
- run: |
- git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
- - name: Install dependencies
- run: uv sync
-
- name: Check type hints
+ if: always()
run: uv run invoke mypy-check
diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml
index 842a9a5..422bebb 100644
--- a/.github/workflows/sync-docs.yml
+++ b/.github/workflows/sync-docs.yml
@@ -13,15 +13,10 @@ jobs:
- name: Checkout voxkit-desktop repository
uses: actions/checkout@v4
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- name: Install uv
- run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+ uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 28f8cab..4db3fbc 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
+ - develop
pull_request:
branches:
- main
+ - develop
permissions:
contents: read
@@ -19,13 +21,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- name: Install uv
uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index 3e933f0..2126f5d 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
+ - develop
pull_request:
branches:
- main
+ - develop
permissions:
contents: read
@@ -19,11 +21,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- name: Install Qt system dependencies
run: |
sudo apt-get update
@@ -49,6 +46,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
- name: Configure Git for private repos
run: |
@@ -61,13 +60,3 @@ jobs:
- name: Run tests
run: |
xvfb-run -a uv run invoke run-tests
-
- - name: Run linting
- run: |
- uv run invoke lint-check
- continue-on-error: true
-
- - name: Run type checking
- run: |
- uv run invoke mypy-check
- continue-on-error: true
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index 13d4bf8..edbe030 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -4,9 +4,11 @@ on:
push:
branches:
- main
+ - develop
pull_request:
branches:
- main
+ - develop
permissions:
contents: read
@@ -19,13 +21,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Python 3.11
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- name: Install uv
uses: astral-sh/setup-uv@v3
+ with:
+ enable-cache: true
- name: Configure Git for private repos
run: |
From 1c1dea8f3f0eab9b2cc452e3c1e817217167ca3a Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Tue, 19 May 2026 11:32:15 -0500
Subject: [PATCH 04/26] Centralize app version in config/VERSION and update
documentation (#141)
* Make config/VERSION the single source of truth for app version
Previously the version was duplicated across pyproject.toml, the
voxkit package __init__, three app_info.yaml files, and the Windows
installer script, and had drifted (0.1.0 / 0.4.0 / 0.4.1). All
consumers now read from config/VERSION (canonicalized to 0.4.1).
* Adapt testing for new version convention
* Update AGENTS.md
---
AGENTS.md | 51 +++++++++++++++--------
config/VERSION | 1 +
config/app_info.yaml | 20 ++++-----
config/profiles/default/app_info.yaml | 20 ++++-----
config/profiles/explanatory/app_info.yaml | 20 ++++-----
installer/windows/VoxKit.iss | 11 ++++-
pyproject.toml | 7 +++-
src/voxkit/__init__.py | 16 ++++++-
src/voxkit/config/app_config.py | 7 +++-
tests/config/test_app_config.py | 21 +++++++---
uv.lock | 1 -
11 files changed, 115 insertions(+), 60 deletions(-)
create mode 100644 config/VERSION
diff --git a/AGENTS.md b/AGENTS.md
index 3853fb7..f7db188 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@ Onboarding guide for coding agents working in this repository.
## Project
-VoxKit is a PyQt6 desktop application for speech pathology research — a GUI front-end over multiple speech toolkits (alignment, training, transcription). Package lives at `src/voxkit/`. Python 3.11+, managed with `uv`.
+VoxKit is a PyQt6 desktop application for speech pathology research; a GUI front-end over multiple speech toolkits (alignment, training, transcription) with a shared storage for interacting with and processing speech datasets. Package lives at `src/voxkit/`. Python 3.11+, managed with `uv`.
## Repository Layout
@@ -15,11 +15,18 @@ src/voxkit/
├── analyzers/ # Dataset metadata extractors (CSV summaries)
├── storage/ # Persistence/CRUD for datasets, models, alignments
├── services/ # External subprocess integrations
-└── config/ # App configuration and startup
+└── config/ # App configuration loaders (profile-aware)
+config/
+├── VERSION # Single source of truth for app version
+├── profile.txt # Active profile name
+├── app_info.yaml # Legacy fallback metadata
+├── pipeline_definitions.yaml # Legacy fallback metadata
+└── profiles// # Per-profile yaml configurations
tests/ # Pytest suite (unit + GUI via pytest-qt)
docs/ # ARCHITECTURE.md, CONTRIBUTING.md, RESEARCH.md
-hooks/ # Pre-commit hooks
-scripts/ # Dev scripts
+hooks/ # Build-time hooks to fix dependency problems in the build
+scripts/ # Dev scripts (incl. build.py for PyInstaller)
+installer/ # Platform installer scripts (Inno Setup for Windows)
main.py # Entry point
```
@@ -33,45 +40,53 @@ Hybrid "unstructured state + signals" PyQt pattern. See `docs/ARCHITECTURE.md` f
- **Async work runs in QThread workers** that emit `pyqtSignal` back to views.
- **Cross-page state** refreshes via parent window calling `reload()` on tab switch.
- **Engines** and **analyzers** each have an abstract base class and a singleton manager for discovery.
+- **Config profiles**: `config/profile.txt` selects an active profile under `config/profiles//`. The loader is in (`src/voxkit/config/app_config.py`).
## Setup & Common Commands
-Use `invoke` (pyinvoke, tasks defined in `tasks.py`) — do not invoke tools directly unless you need a flag the task doesn't expose.
+> **IMPORTANT (read before touching anything):**
+> 1. **Run `invoke setup` first, every fresh checkout.** It installs dependencies (`uv sync`), wires up pre-commit hooks, and prepares the local environment.
+> 2. **Use `invoke` tasks for everything during development.** Do **not** reach for `pytest`, `ruff`, `mypy`, `pyinstaller`, or `uv run …` directly. The tasks in `tasks.py` set the right flags, paths, and env vars; bypassing them produces results that won't match CI or other contributors. Only call a tool directly if you genuinely need a flag the task doesn't expose, and prefer adding the flag to the task over a one-off workaround.
+
+Tasks are defined in `tasks.py` (pyinvoke).
| Command | Purpose |
|---|---|
| `invoke setup` | Install deps + pre-commit hooks (run first) |
| `invoke dev` | Launch the app in dev mode |
+| `invoke watch` | Dev mode with auto-reload on source changes |
| `invoke run-tests` | Unit + GUI tests |
| `invoke test-coverage` | Coverage for core modules |
+| `invoke generate-coverage-badge` | Refresh `coverage.svg` |
+| `invoke generate-documentation` | Build pdoc HTML into `docs/` |
| `invoke lint` / `invoke lint-check` | Ruff lint (fix / check) |
| `invoke format` / `invoke format-check` | Ruff format |
| `invoke mypy-check` | Type check |
-| `invoke build` | Standalone executable (PyInstaller) |
+| `invoke macos-build` / `linux-build` / `windows-build` | Standalone executable (PyInstaller) |
| `invoke clean` | Remove build artifacts |
+| `invoke fresh-slate` | Remove virtual environment and lock file (Dependency troubleshooting) |
| `invoke --list` | Full list |
-## Code Standards
+## Versioning
+
+`config/VERSION` is the single source of truth. All consumers read it:
+
+- `pyproject.toml` via `[tool.setuptools.dynamic] version = {file = ["config/VERSION"]}`
+- `src/voxkit/__init__.py` (`__version__` read at import, handles PyInstaller `_MEIPASS`)
+- `AppConfig.from_yaml` overrides any YAML `version:` with this file (legacy behavior)
+- `installer/windows/VoxKit.iss` reads it via ISPP at compile time
-- **Ruff**: line length 100, double quotes, isort-managed imports. Lints: `E`, `F`, `I`, `S` (bandit). Per-file ignores in `pyproject.toml`.
-- **Mypy**: Python 3.11, `warn_return_any=true`. `tests/` and `main.py` excluded.
-- **Coverage targets**: 70–80% on new business logic in `storage/`, `config/`, `analyzers/`. GUI, engines, and services are deliberately omitted from coverage.
-- Pre-commit runs on every commit — don't bypass with `--no-verify`.
+To bump the version, edit `config/VERSION` and nothing else. Do not reintroduce hardcoded version strings in `__init__.py`, `app_info.yaml`, or the installer.
## Testing
- Framework: `pytest`, with `pytest-qt` for GUI and `pytest-asyncio` for async.
- Write tests for new business logic in `storage/`, `config/`, `analyzers/`. GUI components are excluded from coverage metrics but still testable with `pytest-qt` when useful.
-- Run `invoke run-tests` before reporting a task complete. For UI changes, also launch `invoke dev` and exercise the feature — type checks don't verify user-facing behavior.
-
-## Commit & PR Conventions
-
-Format: `: ` where type ∈ `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Keep commits small and logical. See `docs/CONTRIBUTING.md` for the review process.
## Gotchas for Agents
-- Two test directories exist at repo root: `tests/` (the real suite, in `pyproject.toml` config) and `test/` (untracked scratch). Put new tests in `tests/`.
-- The `pyproject.toml` `name` is still `pypllr-gui` (legacy) but the package is `voxkit`. Don't "fix" this without asking.
- `main.py`, `build.py`, `_frozen_patch.py` are excluded from lint/mypy/coverage — they're build/entry shims.
+- `src/voxkit/__init__.py` eagerly imports all subpackages (including PyQt6 via `gui`) so pdoc can discover them. `import voxkit` is therefore expensive — fine for the app, painful for scripts that just want `__version__`. Don't "optimize" by removing the imports without checking pdoc output.
- Engines and services wrap external binaries; changes there are hard to unit-test and are omitted from coverage by design.
- Dependencies pin `torch==2.8.0` and pull several packages from Git SHAs — don't loosen these casually.
+- PyInstaller bundles `config/` via `scripts/build.py` (`--add-data`); anything new in `config/` that the runtime needs will ship automatically, but custom paths outside `config/` will not.
diff --git a/config/VERSION b/config/VERSION
new file mode 100644
index 0000000..267577d
--- /dev/null
+++ b/config/VERSION
@@ -0,0 +1 @@
+0.4.1
diff --git a/config/app_info.yaml b/config/app_info.yaml
index 5555953..5b71581 100644
--- a/config/app_info.yaml
+++ b/config/app_info.yaml
@@ -2,7 +2,7 @@
# This file contains metadata about the application version and purpose
app_name: "VoxKit"
-version: "0.1.0"
+# version is sourced from config/VERSION (single source of truth)
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
@@ -12,17 +12,17 @@ log_backup_count: 3 # number of rotated files to retain
# Introduction text displayed to users
introduction: |
- VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
- This toolkit enables rigorous phonetic analysis without requiring deep technical
+ VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
+ This toolkit enables rigorous phonetic analysis without requiring deep technical
expertise in machine learning or command-line interfaces.
-
+
Core Workflow:
1. Register and analyze your speech datasets
2. Train custom acoustic models or use pretrained engines (MFA, W2TG)
3. Generate phoneme-level forced alignments with timing precision
4. Extract Goodness of Pronunciation (PLLR) scores for clinical assessment
5. Export results with full provenance tracking for reproducible research
-
+
Key Capabilities:
- Multiple alignment engines (MFA, Wav2TextGrid, WhisperX in development)
- Extensible analyzer system for custom metadata extraction
@@ -38,7 +38,7 @@ release_notes: |
- Enhanced dataset analyzers with custom metadata extraction
- Model management interface with version tracking
- Startup routines for automated asset downloads
-
+
Configuration Changes:
- Researchers can now modify workflows by editing config/pipeline_definitions.yaml
- No code changes required for common workflow adaptations
@@ -49,10 +49,10 @@ contact_info:
github_issues: "https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/issues"
email_support: "bfrey6@wisc.edu"
documentation: "https://voxkit-web.vercel.app/help"
-
+
# Research context
research_context: |
- VoxKit was developed through collaboration between WISCLab and the Brain Behavior
- Analytics Lab to democratize access to state-of-the-art forced alignment tools.
- The platform is designed around established speech pathology research methodologies
+ VoxKit was developed through collaboration between WISCLab and the Brain Behavior
+ Analytics Lab to democratize access to state-of-the-art forced alignment tools.
+ The platform is designed around established speech pathology research methodologies
rather than generic audio processing workflows.
diff --git a/config/profiles/default/app_info.yaml b/config/profiles/default/app_info.yaml
index a1882f8..a374395 100644
--- a/config/profiles/default/app_info.yaml
+++ b/config/profiles/default/app_info.yaml
@@ -2,7 +2,7 @@
# This file contains metadata about the application version and purpose
app_name: "VoxKit"
-version: "0.1.0"
+# version is sourced from config/VERSION (single source of truth)
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
@@ -12,17 +12,17 @@ log_backup_count: 3 # number of rotated files to retain
# Introduction text displayed to users
introduction: |
- VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
- This toolkit enables rigorous phonetic analysis without requiring deep technical
+ VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
+ This toolkit enables rigorous phonetic analysis without requiring deep technical
expertise in machine learning or command-line interfaces.
-
+
Core Workflow:
1. Register and analyze your speech datasets
2. Train custom acoustic models or use pretrained engines (MFA, W2TG)
3. Generate phoneme-level forced alignments with timing precision
4. Extract Goodness of Pronunciation (GOP) scores for clinical assessment
5. Export results with full provenance tracking for reproducible research
-
+
Key Capabilities:
- Multiple alignment engines (MFA, Wav2TextGrid, WhisperX in development)
- Extensible analyzer system for custom metadata extraction
@@ -38,7 +38,7 @@ release_notes: |
- Enhanced dataset analyzers with custom metadata extraction
- Model management interface with version tracking
- Startup routines for automated asset downloads
-
+
Configuration Changes:
- Researchers can now modify workflows by editing config/pipeline_definitions.yaml
- No code changes required for common workflow adaptations
@@ -49,10 +49,10 @@ contact_info:
github_issues: "https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/issues"
email_support: "bfrey6@wisc.edu"
documentation: "https://voxkit-web.vercel.app/help"
-
+
# Research context
research_context: |
- VoxKit was developed through collaboration between WISCLab and the Brain Behavior
- Analytics Lab to democratize access to state-of-the-art forced alignment tools.
- The platform is designed around established speech pathology research methodologies
+ VoxKit was developed through collaboration between WISCLab and the Brain Behavior
+ Analytics Lab to democratize access to state-of-the-art forced alignment tools.
+ The platform is designed around established speech pathology research methodologies
rather than generic audio processing workflows.
diff --git a/config/profiles/explanatory/app_info.yaml b/config/profiles/explanatory/app_info.yaml
index 5555953..5b71581 100644
--- a/config/profiles/explanatory/app_info.yaml
+++ b/config/profiles/explanatory/app_info.yaml
@@ -2,7 +2,7 @@
# This file contains metadata about the application version and purpose
app_name: "VoxKit"
-version: "0.1.0"
+# version is sourced from config/VERSION (single source of truth)
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
@@ -12,17 +12,17 @@ log_backup_count: 3 # number of rotated files to retain
# Introduction text displayed to users
introduction: |
- VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
- This toolkit enables rigorous phonetic analysis without requiring deep technical
+ VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
+ This toolkit enables rigorous phonetic analysis without requiring deep technical
expertise in machine learning or command-line interfaces.
-
+
Core Workflow:
1. Register and analyze your speech datasets
2. Train custom acoustic models or use pretrained engines (MFA, W2TG)
3. Generate phoneme-level forced alignments with timing precision
4. Extract Goodness of Pronunciation (PLLR) scores for clinical assessment
5. Export results with full provenance tracking for reproducible research
-
+
Key Capabilities:
- Multiple alignment engines (MFA, Wav2TextGrid, WhisperX in development)
- Extensible analyzer system for custom metadata extraction
@@ -38,7 +38,7 @@ release_notes: |
- Enhanced dataset analyzers with custom metadata extraction
- Model management interface with version tracking
- Startup routines for automated asset downloads
-
+
Configuration Changes:
- Researchers can now modify workflows by editing config/pipeline_definitions.yaml
- No code changes required for common workflow adaptations
@@ -49,10 +49,10 @@ contact_info:
github_issues: "https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/issues"
email_support: "bfrey6@wisc.edu"
documentation: "https://voxkit-web.vercel.app/help"
-
+
# Research context
research_context: |
- VoxKit was developed through collaboration between WISCLab and the Brain Behavior
- Analytics Lab to democratize access to state-of-the-art forced alignment tools.
- The platform is designed around established speech pathology research methodologies
+ VoxKit was developed through collaboration between WISCLab and the Brain Behavior
+ Analytics Lab to democratize access to state-of-the-art forced alignment tools.
+ The platform is designed around established speech pathology research methodologies
rather than generic audio processing workflows.
diff --git a/installer/windows/VoxKit.iss b/installer/windows/VoxKit.iss
index 0170b76..31c0c41 100644
--- a/installer/windows/VoxKit.iss
+++ b/installer/windows/VoxKit.iss
@@ -1,5 +1,14 @@
#define AppName "VoxKit"
-#define AppVersion "0.4.1"
+
+; AppVersion is read from config/VERSION (single source of truth).
+#define VersionFile "..\..\config\VERSION"
+#define VersionHandle FileOpen(VersionFile)
+#if VersionHandle
+ #define AppVersion Trim(FileRead(VersionHandle))
+ #expr FileClose(VersionHandle)
+#else
+ #error "Could not open config/VERSION"
+#endif
#define AppPublisher "Brain Behavior Analytics Lab"
#define AppURL "https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop"
#define AppExeName "VoxKit.exe"
diff --git a/pyproject.toml b/pyproject.toml
index b5c13ff..e9ecd48 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,8 +3,8 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pypllr-gui"
-version = "0.1.0"
-description = "AI/ML Research -> Clinical Applications (Speech Pathology)"
+dynamic = ["version"]
+description = "PyQt6 workbench bridging audio ML engines and speech-pathology clinical workflows."
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
@@ -212,6 +212,9 @@ show_missing = true
[tool.setuptools.packages.find]
where = ["src"]
+[tool.setuptools.dynamic]
+version = {file = ["config/VERSION"]}
+
[tool.shredguard]
[[tool.shredguard.patterns]]
diff --git a/src/voxkit/__init__.py b/src/voxkit/__init__.py
index 1ae30d9..94d69c0 100644
--- a/src/voxkit/__init__.py
+++ b/src/voxkit/__init__.py
@@ -10,12 +10,24 @@
- **config**: Application and pipeline configuration
"""
-__version__ = "0.4.0"
-__author__ = "Beckett Frey - code@beckettfrey.com"
+import sys
+from pathlib import Path
# Import subpackages for pdoc discoverability (not re-exported in __all__)
from . import analyzers, config, engines, gui, storage
+
+def _read_version() -> str:
+ if getattr(sys, "_MEIPASS", None):
+ root = Path(getattr(sys, "_MEIPASS")) / "config"
+ else:
+ root = Path(__file__).resolve().parents[2] / "config"
+ return (root / "VERSION").read_text(encoding="utf-8").strip()
+
+
+__version__ = _read_version()
+__author__ = "Beckett Frey - code@beckettfrey.com"
+
__all__ = [
"__version__",
"__author__",
diff --git a/src/voxkit/config/app_config.py b/src/voxkit/config/app_config.py
index 83d6328..3201aff 100644
--- a/src/voxkit/config/app_config.py
+++ b/src/voxkit/config/app_config.py
@@ -154,9 +154,14 @@ def from_yaml(cls, config_path: Path) -> "AppConfig":
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
+ # Version is sourced from config/VERSION (single source of truth),
+ # not from per-profile YAML.
+ version_file = get_config_root() / "VERSION"
+ version = version_file.read_text(encoding="utf-8").strip()
+
return cls(
app_name=data.get("app_name", "VoxKit"),
- version=data.get("version", "0.0.0"),
+ version=version,
description=data.get("description", ""),
introduction=data.get("introduction", ""),
help_url=data.get("help_url", "https://voxkit-web.vercel.app/help"),
diff --git a/tests/config/test_app_config.py b/tests/config/test_app_config.py
index e808e12..1bb288d 100644
--- a/tests/config/test_app_config.py
+++ b/tests/config/test_app_config.py
@@ -71,7 +71,6 @@ class TestAppConfigFromYaml:
def test_from_yaml_success(self, tmp_path):
yaml_content = """
app_name: MyApp
-version: 1.2.3
description: My application description
introduction: Welcome to MyApp
help_url: http://myapp.com/help
@@ -84,7 +83,6 @@ def test_from_yaml_success(self, tmp_path):
config = AppConfig.from_yaml(config_file)
assert config.app_name == "MyApp"
- assert config.version == "1.2.3"
assert config.description == "My application description"
assert config.introduction == "Welcome to MyApp"
assert config.help_url == "http://myapp.com/help"
@@ -100,7 +98,6 @@ def test_from_yaml_with_defaults(self, tmp_path):
config = AppConfig.from_yaml(config_file)
assert config.app_name == "VoxKit"
- assert config.version == "0.0.0"
assert config.description == ""
assert config.introduction == ""
assert config.help_url == "https://voxkit-web.vercel.app/help"
@@ -114,7 +111,6 @@ def test_from_yaml_with_defaults(self, tmp_path):
def test_from_yaml_partial_config(self, tmp_path):
yaml_content = """
app_name: PartialApp
-version: 0.1.0
"""
config_file = tmp_path / "app_info.yaml"
config_file.write_text(yaml_content)
@@ -122,11 +118,26 @@ def test_from_yaml_partial_config(self, tmp_path):
config = AppConfig.from_yaml(config_file)
assert config.app_name == "PartialApp"
- assert config.version == "0.1.0"
assert config.description == ""
assert config.introduction == ""
+class TestVersionFile:
+ """Verify the canonical version source (config/VERSION)."""
+
+ def test_version_file_exists(self):
+ version_file = get_config_root() / "VERSION"
+ assert version_file.exists(), f"Expected canonical version file at {version_file}"
+
+ def test_version_file_has_nonempty_version(self):
+ version = (get_config_root() / "VERSION").read_text(encoding="utf-8").strip()
+ assert version, "config/VERSION is empty"
+ # Loose sanity check: at least one digit and a dot (e.g. "0.4.1").
+ assert any(ch.isdigit() for ch in version) and "." in version, (
+ f"config/VERSION does not look like a version string: {version!r}"
+ )
+
+
class TestAppConfigLoadDefault:
def test_load_default_returns_config(self):
# This tests the actual config file in the project
diff --git a/uv.lock b/uv.lock
index 5456aa0..fab6d8a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2978,7 +2978,6 @@ wheels = [
[[package]]
name = "pypllr-gui"
-version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },
From cb4af8e83e71dfbd54b01d487cc6b2187557e060 Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Thu, 21 May 2026 11:31:01 -0700
Subject: [PATCH 05/26] Init shared constants procedure (#142)
* Move possible audio extensions to shared constants
* Move possible tools of an engine to shared constant
* Move audio types to storage instead
* Move help url to shared constants instead
* Remove deprecated function get_profile_config
* Reformat badges in readme ocd
* Copilot fix
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Match constant to duplicate definition per feedback
* Copilot feedback
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Lint code [skip ci]
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
README.md | 36 +++++--------------
src/voxkit/analyzers/audio_format_profile.py | 4 ++-
.../analyzers/clip_duration_statistics.py | 4 ++-
src/voxkit/analyzers/default_analyzer.py | 4 ++-
src/voxkit/config/__init__.py | 4 +--
src/voxkit/config/app_config.py | 28 ++++-----------
src/voxkit/config/constants.py | 8 +++++
src/voxkit/config/startup_config.py | 3 +-
src/voxkit/engines/__init__.py | 9 ++---
src/voxkit/engines/base.py | 17 +++++----
src/voxkit/engines/constants.py | 4 +++
.../gui/pages/pipeline/viewer_stacker.py | 3 +-
src/voxkit/storage/alignments.py | 4 +--
src/voxkit/storage/config.py | 20 -----------
src/voxkit/storage/constants.py | 21 +++++++++++
src/voxkit/storage/datasets.py | 6 ++--
src/voxkit/storage/models.py | 2 +-
src/voxkit/storage/utils.py | 2 +-
tests/config/test_app_config.py | 7 +---
tests/engines/test_engine_manager.py | 2 +-
tests/storage/test_models.py | 6 ++--
tests/storage/test_setup.py | 2 +-
22 files changed, 86 insertions(+), 110 deletions(-)
create mode 100644 src/voxkit/config/constants.py
create mode 100644 src/voxkit/engines/constants.py
delete mode 100644 src/voxkit/storage/config.py
create mode 100644 src/voxkit/storage/constants.py
diff --git a/README.md b/README.md
index 2cbaaed..fa6742b 100644
--- a/README.md
+++ b/README.md
@@ -3,37 +3,17 @@
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
> [!IMPORTANT]
diff --git a/src/voxkit/analyzers/audio_format_profile.py b/src/voxkit/analyzers/audio_format_profile.py
index 4844d81..068d1b1 100644
--- a/src/voxkit/analyzers/audio_format_profile.py
+++ b/src/voxkit/analyzers/audio_format_profile.py
@@ -18,6 +18,8 @@
from pathlib import Path
from typing import Any, Dict, List
+from voxkit.storage.constants import SUPERSET_AUDIO_EXTENSIONS
+
from .base import DatasetAnalyzer
logger = logging.getLogger(__name__)
@@ -38,7 +40,7 @@ def analyze(self, dataset_path: str) -> List[Dict[str, Any]]:
import torchaudio
results = []
- audio_extensions = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
+ audio_extensions = SUPERSET_AUDIO_EXTENSIONS
try:
for entry in os.scandir(dataset_path):
diff --git a/src/voxkit/analyzers/clip_duration_statistics.py b/src/voxkit/analyzers/clip_duration_statistics.py
index 5f8d259..8b5b348 100644
--- a/src/voxkit/analyzers/clip_duration_statistics.py
+++ b/src/voxkit/analyzers/clip_duration_statistics.py
@@ -17,6 +17,8 @@
from pathlib import Path
from typing import Any, Dict, List
+from voxkit.storage.constants import SUPERSET_AUDIO_EXTENSIONS
+
from .base import DatasetAnalyzer
logger = logging.getLogger(__name__)
@@ -37,7 +39,7 @@ def analyze(self, dataset_path: str) -> List[Dict[str, Any]]:
import torchaudio
results = []
- audio_extensions = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
+ audio_extensions = SUPERSET_AUDIO_EXTENSIONS
try:
for entry in os.scandir(dataset_path):
diff --git a/src/voxkit/analyzers/default_analyzer.py b/src/voxkit/analyzers/default_analyzer.py
index d9b7383..c7ab6b5 100644
--- a/src/voxkit/analyzers/default_analyzer.py
+++ b/src/voxkit/analyzers/default_analyzer.py
@@ -17,6 +17,8 @@
from pathlib import Path
from typing import Any, Dict, List
+from voxkit.storage.constants import SUPERSET_AUDIO_EXTENSIONS
+
from .base import DatasetAnalyzer
@@ -43,7 +45,7 @@ def analyze(self, dataset_path: str) -> List[Dict[str, Any]]:
``audio_file_count``.
"""
results = []
- audio_extensions = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
+ audio_extensions = SUPERSET_AUDIO_EXTENSIONS
try:
for entry in os.scandir(dataset_path):
diff --git a/src/voxkit/config/__init__.py b/src/voxkit/config/__init__.py
index 0cc437e..b1a6203 100644
--- a/src/voxkit/config/__init__.py
+++ b/src/voxkit/config/__init__.py
@@ -28,6 +28,7 @@
get_profile_config_path,
resolve_config_file,
)
+from voxkit.config.constants import DEFAULT_HELP_URL
from voxkit.config.logging_config import (
LOG_FILE,
reset_logging,
@@ -40,7 +41,6 @@
get_pipeline_config,
)
from voxkit.config.startup_config import (
- HELP_URL,
STARTUP_SCRIPT,
AppName,
Defaults,
@@ -63,7 +63,7 @@
"UIConfig",
"get_pipeline_config",
# Startup config
- "HELP_URL",
+ "DEFAULT_HELP_URL",
"AppName",
"Dimensions",
"Defaults",
diff --git a/src/voxkit/config/app_config.py b/src/voxkit/config/app_config.py
index 3201aff..aa46fe4 100644
--- a/src/voxkit/config/app_config.py
+++ b/src/voxkit/config/app_config.py
@@ -14,6 +14,8 @@
import yaml
+from voxkit.config.constants import DEFAULT_HELP_URL
+
def get_config_root() -> Path:
"""Get the path to the config root directory.
@@ -96,30 +98,12 @@ def resolve_config_file(filename: str) -> Path:
if default_path.exists():
return default_path
- # Fall back to legacy location (config root)
- legacy_path = config_root / filename
- if legacy_path.exists():
- return legacy_path
-
+ # Throw error if not found in either location
raise FileNotFoundError(
- f"Config file '{filename}' not found in profile '{profile}', "
- f"default profile, or config root"
+ f"Config file '{filename}' not found in profile '{profile}' or default profile"
)
-# Legacy alias for backwards compatibility
-def get_config_path() -> Path:
- """Get the path to the config directory.
-
- Deprecated: Use get_profile_config_path() for profile-aware loading,
- or get_config_root() for the config root directory.
-
- Returns:
- Path to the active profile's config directory
- """
- return get_profile_config_path()
-
-
@dataclass
class AppConfig:
"""Application configuration data class."""
@@ -128,7 +112,7 @@ class AppConfig:
version: str
description: str
introduction: str
- help_url: str = "https://voxkit-web.vercel.app/help"
+ help_url: str | None = None
release_date: Optional[str] = None
release_notes: Optional[str] = None
log_max_bytes: int = 5 * 1024 * 1024
@@ -164,7 +148,7 @@ def from_yaml(cls, config_path: Path) -> "AppConfig":
version=version,
description=data.get("description", ""),
introduction=data.get("introduction", ""),
- help_url=data.get("help_url", "https://voxkit-web.vercel.app/help"),
+ help_url=data.get("help_url", DEFAULT_HELP_URL),
release_date=data.get("release_date"),
release_notes=data.get("release_notes"),
log_max_bytes=int(data.get("log_max_bytes", 5 * 1024 * 1024)),
diff --git a/src/voxkit/config/constants.py b/src/voxkit/config/constants.py
new file mode 100644
index 0000000..65ed8f4
--- /dev/null
+++ b/src/voxkit/config/constants.py
@@ -0,0 +1,8 @@
+"""Constants relevant to configuration and setup.
+
+Constants
+---------
+- **DEFAULT_HELP_URL**: URL for user help documentation
+"""
+
+DEFAULT_HELP_URL = "https://voxkit-web.vercel.app/help"
diff --git a/src/voxkit/config/startup_config.py b/src/voxkit/config/startup_config.py
index 6bd2491..334c520 100644
--- a/src/voxkit/config/startup_config.py
+++ b/src/voxkit/config/startup_config.py
@@ -3,7 +3,7 @@
from voxkit.services.mfa import download_acoustic_model
from voxkit.storage import models
-from voxkit.storage.config import MODELS_ROOT
+from voxkit.storage.constants import MODELS_ROOT
from voxkit.storage.models import download_and_copy_huggingface_model
from voxkit.storage.utils import get_storage_root
@@ -18,7 +18,6 @@
}
Mode = Literal["MFAENGINE", "W2TGENGINE"]
-HELP_URL = "https://voxkit-web.vercel.app/help"
def startup_routine():
diff --git a/src/voxkit/engines/__init__.py b/src/voxkit/engines/__init__.py
index 04d5aed..2b2ecde 100644
--- a/src/voxkit/engines/__init__.py
+++ b/src/voxkit/engines/__init__.py
@@ -6,7 +6,7 @@
- **EngineManager.list_engines**: List registered engine IDs
- **EngineManager.get_engine**: Retrieve engine instance by ID
- **EngineManager.get_tool_providers**: Get engines providing a specific tool type
-- **ToolType**: Literal type for compatible tool types
+- **AVAILABLE_TOOLS**: Literal type for compatible tool types
Available Engines
-----------------
@@ -48,7 +48,8 @@
from typing import List
-from .base import AlignmentEngine, ToolType
+from .base import AlignmentEngine
+from .constants import AVAILABLE_TOOLS
from .faster_whisper_engine import FasterWhisperEngine
from .mfa_engine import MFAEngine
from .w2tg_engine import W2TGEngine
@@ -82,7 +83,7 @@ def get_engine(self, engine_id: str) -> AlignmentEngine:
except KeyError:
raise ValueError(f"No engine with id: {engine_id}")
- def get_tool_providers(self, tool: ToolType) -> dict[str, AlignmentEngine]:
+ def get_tool_providers(self, tool: AVAILABLE_TOOLS) -> dict[str, AlignmentEngine]:
"""Return a list of engines that provide the specified tool type."""
engines = {}
for _, engine in self._engines.items():
@@ -97,4 +98,4 @@ def get_tool_providers(self, tool: ToolType) -> dict[str, AlignmentEngine]:
faster_whisper = FasterWhisperEngine(id="FASTERWHISPERENGINE")
engines = EngineManager({mfa.id: mfa, faster_whisper.id: faster_whisper, w2tg.id: w2tg})
-__all__ = ["engines", "ToolType"]
+__all__ = ["engines", "AVAILABLE_TOOLS"]
diff --git a/src/voxkit/engines/base.py b/src/voxkit/engines/base.py
index ae34871..bc23134 100644
--- a/src/voxkit/engines/base.py
+++ b/src/voxkit/engines/base.py
@@ -32,8 +32,9 @@ def align(self, dataset_id: str, model_id: str) -> None:
import json
from abc import ABC, abstractmethod
from pathlib import Path
-from typing import Any, Literal
+from typing import Any
+from voxkit.engines.constants import AVAILABLE_TOOLS
from voxkit.storage.utils import get_storage_root
"""
@@ -42,18 +43,16 @@ def align(self, dataset_id: str, model_id: str) -> None:
has its own settings that are stored in a JSON file.
"""
-ToolType = Literal["train", "align", "transcribe"]
-
class AlignmentEngine(ABC):
"""
Abstract base class for alignment engines.
- Subclasses must implement at least one ToolType operation and provide
+ Subclasses must implement at least one AVAILABLE_TOOLS operation and provide
specific validation criteria.
Attributes:
- settings_configurations (dict[ToolType, Any]): Mapping of
+ settings_configurations (dict[AVAILABLE_TOOLS, Any]): Mapping of
tool type names ("train"/"align") to their store configuration.
reference_url (str | None): Optional reference URL for the engine.
description (str | None): Human-readable description of the engine.
@@ -63,7 +62,7 @@ class AlignmentEngine(ABC):
def __init__(
self,
- settings_configurations: dict[ToolType, Any],
+ settings_configurations: dict[AVAILABLE_TOOLS, Any],
reference_url: str | None = None,
description: str | None = None,
human_readable_name: str | None = None,
@@ -218,7 +217,7 @@ def _get_default_settings(self, cfg: Any) -> dict:
"""
return {field.name: field.default_value for field in (cfg.fields or [])}
- def get_settings(self, tool_type: ToolType) -> dict:
+ def get_settings(self, tool_type: AVAILABLE_TOOLS) -> dict:
"""
Load and validate settings for a specific tool.
@@ -269,7 +268,7 @@ def get_settings(self, tool_type: ToolType) -> dict:
return settings
- def get_settings_config(self, tool_type: ToolType) -> Any:
+ def get_settings_config(self, tool_type: AVAILABLE_TOOLS) -> Any:
"""
Return the :class:`Any` for a tool type.
@@ -287,7 +286,7 @@ def get_settings_config(self, tool_type: ToolType) -> Any:
raise ValueError(f"No settings configuration found for tool type: {tool_type}")
return config
- def has_tool(self, tool_type: ToolType) -> bool:
+ def has_tool(self, tool_type: AVAILABLE_TOOLS) -> bool:
"""Check if the engine has a tool of the specified type."""
return tool_type in self.settings_configurations
diff --git a/src/voxkit/engines/constants.py b/src/voxkit/engines/constants.py
new file mode 100644
index 0000000..ac8429d
--- /dev/null
+++ b/src/voxkit/engines/constants.py
@@ -0,0 +1,4 @@
+from typing import Literal
+
+# New engines can implement these tools or a subset of them
+AVAILABLE_TOOLS = Literal["train", "align", "transcribe"]
diff --git a/src/voxkit/gui/pages/pipeline/viewer_stacker.py b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
index 293b7c9..b39eef5 100644
--- a/src/voxkit/gui/pages/pipeline/viewer_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/viewer_stacker.py
@@ -40,6 +40,7 @@
from voxkit.gui.pages.pipeline.base_stacker import BaseStacker
from voxkit.gui.styles import Buttons, Colors, Containers, Labels
from voxkit.storage import alignments, datasets
+from voxkit.storage.constants import SUPERSET_AUDIO_EXTENSIONS
if TYPE_CHECKING:
from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer
@@ -52,7 +53,7 @@
MULTIMEDIA_AVAILABLE = False
-_AUDIO_EXTENSIONS = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
+_AUDIO_EXTENSIONS = SUPERSET_AUDIO_EXTENSIONS
_SILENCE_LABELS = {"", "sp", "sil", "", "spn"}
# ---------------------------------------------------------------------------
diff --git a/src/voxkit/storage/alignments.py b/src/voxkit/storage/alignments.py
index 618ca68..dd1cf9a 100644
--- a/src/voxkit/storage/alignments.py
+++ b/src/voxkit/storage/alignments.py
@@ -38,7 +38,7 @@
from pathlib import Path
from typing import List, Literal, Tuple, TypedDict
-from .config import ALIGNMENTS_ROOT
+from .constants import ALIGNMENTS_ROOT, SUPERSET_AUDIO_EXTENSIONS
from .datasets import _get_dataset_root, get_dataset_metadata
from .models import ModelMetadata, get_model_metadata
from .utils import generate_unique_id, readable_from_unique_id
@@ -192,7 +192,7 @@ def create_alignment(
return False, f"Failed to create alignment metadata: {str(e)}"
-_AUDIO_EXTS = (".wav", ".flac", ".mp3", ".ogg", ".m4a")
+_AUDIO_EXTS = SUPERSET_AUDIO_EXTENSIONS
def validate_hand_alignments(dataset_path: Path, hand_path: Path) -> Tuple[bool, str]:
diff --git a/src/voxkit/storage/config.py b/src/voxkit/storage/config.py
deleted file mode 100644
index 9f3312f..0000000
--- a/src/voxkit/storage/config.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""This module contains constants for the VoxKit storage system.
-
-Constants
----------
-- **STORAGE_ROOT**: Root directory for all VoxKit storage (~/.voxkit)
-- **MODELS_ROOT**: Subdirectory for model storage relative to engine directory
-- **DATASETS_ROOT**: Subdirectory for dataset storage relative to STORAGE_ROOT
-- **ALIGNMENTS_ROOT**: Subdirectory for alignments relative to dataset directory
-
-Notes
------
-- STORAGE_ROOT uses tilde (~) notation to reference the user's home directory
-- All paths are relative to appropriate parent directories in the hierarchy
-- The directory structure is created automatically on first use
-"""
-
-STORAGE_ROOT = "~/.voxkit" # Root directory for all storage
-MODELS_ROOT = "train" # Path from STORAGE_ROOT to models
-DATASETS_ROOT = "datasets" # Path from STORAGE_ROOT to datasets
-ALIGNMENTS_ROOT = "alignments" # Path from STORAGE_ROOT/DATASETS_ROOT to alignments
diff --git a/src/voxkit/storage/constants.py b/src/voxkit/storage/constants.py
new file mode 100644
index 0000000..6270649
--- /dev/null
+++ b/src/voxkit/storage/constants.py
@@ -0,0 +1,21 @@
+"""This module contains constants for the VoxKit storage layer.
+
+Constants
+---------
+- **STORAGE_ROOT**: Root directory for all VoxKit storage (~/.voxkit)
+- **MODELS_ROOT**: Subdirectory for model storage relative to engine directory
+- **DATASETS_ROOT**: Subdirectory for dataset storage relative to STORAGE_ROOT
+- **ALIGNMENTS_ROOT**: Subdirectory for alignments relative to dataset directory
+- **SUPERSET_AUDIO_EXTENSIONS**: Comprehensive set of audio file extensions
+
+Notes
+-----
+- STORAGE_ROOT uses tilde (~) notation to reference the user's home directory
+- All paths are relative to appropriate parent directories in the hierarchy
+"""
+
+STORAGE_ROOT: str = "~/.voxkit" # Root directory for all storage
+MODELS_ROOT: str = "train" # Path from STORAGE_ROOT to models
+DATASETS_ROOT: str = "datasets" # Path from STORAGE_ROOT to datasets
+ALIGNMENTS_ROOT: str = "alignments" # Path from STORAGE_ROOT/DATASETS_ROOT to alignments
+SUPERSET_AUDIO_EXTENSIONS: frozenset[str] = frozenset({".wav", ".flac", ".mp3", ".ogg", ".m4a"})
diff --git a/src/voxkit/storage/datasets.py b/src/voxkit/storage/datasets.py
index 2017112..b67a8fb 100644
--- a/src/voxkit/storage/datasets.py
+++ b/src/voxkit/storage/datasets.py
@@ -43,7 +43,7 @@
from pathlib import Path
from typing import Any, List, Literal, Tuple, TypedDict
-from voxkit.storage.config import ALIGNMENTS_ROOT, DATASETS_ROOT
+from voxkit.storage.constants import ALIGNMENTS_ROOT, DATASETS_ROOT, SUPERSET_AUDIO_EXTENSIONS
from voxkit.storage.utils import generate_unique_id, get_storage_root, readable_from_unique_id
@@ -627,9 +627,7 @@ def validate_dataset(dataset_path: Path, transcribed: bool = True) -> Tuple[bool
for speaker in speaker_dirs:
speaker_path = os.path.join(dataset_path, speaker)
audio_files = [
- f
- for f in os.listdir(speaker_path)
- if f.endswith((".wav", ".flac", ".mp3", ".ogg", ".m4a"))
+ f for f in os.listdir(speaker_path) if f.endswith(tuple(SUPERSET_AUDIO_EXTENSIONS))
]
if not audio_files:
diff --git a/src/voxkit/storage/models.py b/src/voxkit/storage/models.py
index b197e94..93e4a79 100644
--- a/src/voxkit/storage/models.py
+++ b/src/voxkit/storage/models.py
@@ -40,7 +40,7 @@
from voxkit.storage.utils import generate_unique_id, get_storage_root, readable_from_unique_id
-from .config import MODELS_ROOT
+from .constants import MODELS_ROOT
class ModelMetadata(TypedDict):
diff --git a/src/voxkit/storage/utils.py b/src/voxkit/storage/utils.py
index 3f25184..7a297a5 100644
--- a/src/voxkit/storage/utils.py
+++ b/src/voxkit/storage/utils.py
@@ -25,7 +25,7 @@
from pathlib import Path
from typing import Any
-from .config import STORAGE_ROOT
+from .constants import STORAGE_ROOT
_id_lock = threading.Lock()
_last_id_dt: datetime | None = None
diff --git a/tests/config/test_app_config.py b/tests/config/test_app_config.py
index 1bb288d..bb72ebe 100644
--- a/tests/config/test_app_config.py
+++ b/tests/config/test_app_config.py
@@ -6,7 +6,6 @@
AppConfig,
get_active_profile,
get_app_config,
- get_config_path,
get_config_root,
get_profile_config_path,
)
@@ -35,10 +34,6 @@ def test_get_profile_config_path_is_inside_profiles(self):
# Should be config/profiles/
assert result.parent.name == "profiles"
- def test_get_config_path_is_alias_for_profile_path(self):
- # get_config_path is now an alias for get_profile_config_path
- assert get_config_path() == get_profile_config_path()
-
class TestAppConfig:
def test_dataclass_fields(self):
@@ -52,7 +47,7 @@ def test_dataclass_fields(self):
assert config.version == "1.0.0"
assert config.description == "Test description"
assert config.introduction == "Test intro"
- assert config.help_url == "https://voxkit-web.vercel.app/help"
+ assert config.help_url is None
config = AppConfig(
app_name="TestApp",
version="2.0.0",
diff --git a/tests/engines/test_engine_manager.py b/tests/engines/test_engine_manager.py
index 1356ea0..93318e4 100644
--- a/tests/engines/test_engine_manager.py
+++ b/tests/engines/test_engine_manager.py
@@ -35,7 +35,7 @@ def test_get_engine_not_found(self):
assert "No engine with id" in str(exc_info.value)
def test_get_tool_providers_align(self):
- # ToolType is Literal["train", "align", "transcribe"]
+ # AVAILABLE_TOOLS is Literal["train", "align", "transcribe"]
providers = engines.get_tool_providers("align")
assert isinstance(providers, dict)
# At least some engines should provide alignment
diff --git a/tests/storage/test_models.py b/tests/storage/test_models.py
index 95f325b..17ee9ba 100644
--- a/tests/storage/test_models.py
+++ b/tests/storage/test_models.py
@@ -631,7 +631,7 @@ def test_import_models_success(self, monkeypatch):
import json
from voxkit.storage import models
- from voxkit.storage.config import MODELS_ROOT
+ from voxkit.storage.constants import MODELS_ROOT
from voxkit.storage.models import import_models
monkeypatch.setattr(models, "get_storage_root", mock_get_storage_root)
@@ -674,7 +674,7 @@ def test_import_models_paths_rewritten(self, monkeypatch):
import json
from voxkit.storage import models
- from voxkit.storage.config import MODELS_ROOT
+ from voxkit.storage.constants import MODELS_ROOT
from voxkit.storage.models import import_models, list_models
monkeypatch.setattr(models, "get_storage_root", mock_get_storage_root)
@@ -737,7 +737,7 @@ def test_import_models_engine_mismatch(self, monkeypatch):
import json
from voxkit.storage import models
- from voxkit.storage.config import MODELS_ROOT
+ from voxkit.storage.constants import MODELS_ROOT
from voxkit.storage.models import import_models
monkeypatch.setattr(models, "get_storage_root", mock_get_storage_root)
diff --git a/tests/storage/test_setup.py b/tests/storage/test_setup.py
index aee3508..ba6f518 100644
--- a/tests/storage/test_setup.py
+++ b/tests/storage/test_setup.py
@@ -1,7 +1,7 @@
import shutil
from pathlib import Path
-from voxkit.storage.config import MODELS_ROOT
+from voxkit.storage.constants import MODELS_ROOT
ENGINE_IDS = ["ENGINE_A", "ENGINE_B", "ENGINE_C"]
From fb5553e4fc2ad29ce0b566df8c9a2a03e61a8116 Mon Sep 17 00:00:00 2001
From: Beckett <83560790+BeckettFrey@users.noreply.github.com>
Date: Tue, 30 Jun 2026 10:34:22 -0500
Subject: [PATCH 06/26] Let users set a custom conda path for MFA (Windows)
(#149)
MFA shells out to conda, which previously had to be on PATH or in one of a
few hardcoded Windows install locations. Users with a non-standard
Anaconda/Miniconda install (common on Windows) had no way to point VoxKit at
their conda.exe.
- Add a "Conda Path" field to the MFA align and train settings dialogs.
- _find_conda() now accepts an explicit path and also honors a
VOXKIT_CONDA_PATH env var, both taking precedence over auto-detection.
- Thread the configured path through the MFA service entry points
(run_mfa_align, run_mfa_adapt, ensure_dictionary_downloaded,
_ensure_mfa_server_running).
- Blank/whitespace settings normalize to None, preserving auto-detection.
- Add unit tests for the resolution precedence.
---
src/voxkit/engines/mfa_engine.py | 54 ++++++++++++++++++++++++--
src/voxkit/services/mfa.py | 66 +++++++++++++++++++++++++-------
tests/services/__init__.py | 0
tests/services/test_mfa.py | 56 +++++++++++++++++++++++++++
4 files changed, 158 insertions(+), 18 deletions(-)
create mode 100644 tests/services/__init__.py
create mode 100644 tests/services/test_mfa.py
diff --git a/src/voxkit/engines/mfa_engine.py b/src/voxkit/engines/mfa_engine.py
index 7cfde55..3dd26a1 100644
--- a/src/voxkit/engines/mfa_engine.py
+++ b/src/voxkit/engines/mfa_engine.py
@@ -11,8 +11,12 @@
--------
Stored at ``~/.voxkit/MFAENGINE/{tool}/settings.json``:
-- **align**: dictionary, file_type
-- **train**: epochs, use_gpu
+- **align**: dictionary, file_type, conda_path
+- **train**: dictionary, num_iterations, use_gpu, conda_path
+
+``conda_path`` lets users (mainly on Windows) point VoxKit at a specific
+``conda``/``conda.exe`` when it is not discoverable on PATH. Leaving it blank
+keeps the existing auto-detection behavior.
Notes
-----
@@ -32,6 +36,29 @@
from voxkit.storage import alignments, datasets, models
from .base import AlignmentEngine
+from .constants import AVAILABLE_TOOLS
+
+
+def _conda_path_field() -> FieldConfig:
+ """Build the shared 'Conda Path' settings field used by every MFA tool.
+
+ The field is optional: a blank value preserves the existing auto-detection
+ in ``voxkit.services.mfa._find_conda``. It exists primarily so Windows users
+ whose conda install is not on PATH can point VoxKit straight at their
+ ``conda.exe``.
+ """
+ return FieldConfig(
+ name="conda_path",
+ label="Conda Path",
+ field_type=FieldType.LINEEDIT,
+ default_value="",
+ placeholder="auto-detect",
+ tooltip=(
+ "Full path to the conda executable. Leave blank to auto-detect. "
+ "Mainly useful on Windows when conda.exe is not on PATH "
+ r"(e.g. C:\Users\me\miniconda3\Scripts\conda.exe)."
+ ),
+ )
class MFAEngine(AlignmentEngine):
@@ -44,7 +71,7 @@ def __init__(self, id: str | None = None):
settings_configurations={
"align": SettingsConfig(
title="MFA Aligner Settings",
- dimensions=(400, 350),
+ dimensions=(460, 380),
apply_blur=True,
fields=[
FieldConfig(
@@ -61,12 +88,13 @@ def __init__(self, id: str | None = None):
default_value="wav",
tooltip="Specify the audio file type (e.g., wav, flac).",
),
+ _conda_path_field(),
],
store_file="MFAENGINE/align/settings.json",
),
"train": SettingsConfig(
title="MFA Trainer Settings",
- dimensions=(400, 350),
+ dimensions=(460, 440),
apply_blur=True,
fields=[
FieldConfig(
@@ -92,6 +120,7 @@ def __init__(self, id: str | None = None):
default_value=False,
tooltip="Enable GPU acceleration for faster training.",
),
+ _conda_path_field(),
],
store_file="MFAENGINE/train/settings.json",
),
@@ -147,6 +176,7 @@ def align(self, dataset_id: str, model_id: str) -> None:
corpus_dir=str(corpus_path),
model_path=str(model_path),
output_dir=str(alignment_output_path),
+ conda_path=self._configured_conda_path("align"),
)
alignments.update_alignment(
dataset_id=dataset_id,
@@ -234,10 +264,26 @@ def train_aligner(
corpus_dir=str(audio_root),
base_model_path=str(base_model_path),
output_model_path=str(new_model_path),
+ conda_path=self._configured_conda_path("train"),
)
except Exception as e:
raise RuntimeError(f"MFA model training failed: {e}")
+ def _configured_conda_path(self, tool_type: AVAILABLE_TOOLS) -> str | None:
+ """Return the user-configured conda path for a tool, or None if unset.
+
+ Reads the persisted settings for ``tool_type`` and normalizes a blank
+ or whitespace-only value to ``None`` so the service layer falls back to
+ auto-detection.
+ """
+ try:
+ value = self.get_settings(tool_type).get("conda_path")
+ except Exception:
+ return None
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
+
def _validate_align_settings(self, settings: dict) -> bool:
return True # Implement validation logic for align settings here
diff --git a/src/voxkit/services/mfa.py b/src/voxkit/services/mfa.py
index bf373ed..b6c331f 100755
--- a/src/voxkit/services/mfa.py
+++ b/src/voxkit/services/mfa.py
@@ -12,8 +12,33 @@ def _no_window() -> dict:
return {}
-def _find_conda() -> str:
- """Return the conda executable path, checking common install locations on Windows."""
+def _find_conda(conda_path: str | None = None) -> str:
+ """Return the conda executable path.
+
+ Resolution order:
+ 1. ``conda_path`` argument, when it points to an existing file. This is
+ the path a user configures in the MFA engine settings, primarily so
+ Windows users with a non-standard Anaconda/Miniconda install can
+ point VoxKit straight at their ``conda.exe``.
+ 2. The ``VOXKIT_CONDA_PATH`` environment variable, when it points to an
+ existing file.
+ 3. ``conda`` on the system PATH.
+ 4. Common install locations on Windows.
+
+ Args:
+ conda_path: Optional user-configured path to the conda executable.
+
+ Raises:
+ FileNotFoundError: If conda cannot be located through any of the above.
+ """
+ # User-configured path (settings dialog) or environment override take
+ # precedence over auto-detection so a deliberate choice always wins.
+ for candidate in (conda_path, os.environ.get("VOXKIT_CONDA_PATH")):
+ if candidate:
+ resolved = Path(candidate).expanduser()
+ if resolved.exists():
+ return str(resolved)
+
# Fast path: conda is already on PATH
if shutil.which("conda"):
return "conda"
@@ -44,20 +69,25 @@ def _find_conda() -> str:
raise FileNotFoundError(
"conda not found. Install Miniconda from https://docs.conda.io/en/latest/miniconda.html "
"and create the aligner environment with: "
- "conda create -n aligner -c conda-forge montreal-forced-aligner"
+ "conda create -n aligner -c conda-forge montreal-forced-aligner. "
+ "If conda is installed but not on PATH (common on Windows), set its full path in the "
+ "MFA engine settings ('Conda Path') or via the VOXKIT_CONDA_PATH environment variable."
)
-def ensure_dictionary_downloaded(dictionary_name: str = "english_us_arpa") -> None:
+def ensure_dictionary_downloaded(
+ dictionary_name: str = "english_us_arpa", conda_path: str | None = None
+) -> None:
"""Ensure the specified MFA dictionary is downloaded.
Args:
dictionary_name: Name of the dictionary to download (default: "english_us_arpa").
+ conda_path: Optional user-configured path to the conda executable.
Raises:
AssertionError: If dictionary download fails and dictionary is not available.
"""
- conda = _find_conda()
+ conda = _find_conda(conda_path)
download_cmd = [
conda,
"run",
@@ -97,7 +127,7 @@ def ensure_dictionary_downloaded(dictionary_name: str = "english_us_arpa") -> No
print(f"[mfa] Dictionary '{dictionary_name}' is ready.")
-def _ensure_mfa_server_running() -> None:
+def _ensure_mfa_server_running(conda_path: str | None = None) -> None:
"""Start MFA's bundled Postgres server on Windows. No-op elsewhere.
Why: MFA 3.3.x's SQLite backend has a multiprocessing race in
@@ -111,7 +141,7 @@ def _ensure_mfa_server_running() -> None:
if sys.platform != "win32":
return
- conda = _find_conda()
+ conda = _find_conda(conda_path)
# Both calls are idempotent: `init` errors if the server dir already exists,
# `start` errors if it's already running. Either error state is the goal,
# so we ignore returncodes and only guard against true failures (timeout,
@@ -131,7 +161,12 @@ def _ensure_mfa_server_running() -> None:
def run_mfa_align(
- corpus_dir, model_path, output_dir, dictionary_name="english_us_arpa", eval_dir=None
+ corpus_dir,
+ model_path,
+ output_dir,
+ dictionary_name="english_us_arpa",
+ eval_dir=None,
+ conda_path=None,
) -> None:
"""
Run MFA align command with the provided arguments.
@@ -142,16 +177,17 @@ def run_mfa_align(
output_dir: Path to output TextGrids.
dictionary_name: MFA dictionary name (default: "english_us_arpa").
eval_dir: Optional path to reference alignments for evaluation.
+ conda_path: Optional user-configured path to the conda executable.
Raises:
AssertionError: If dictionary is not available.
subprocess.CalledProcessError: If MFA alignment fails.
"""
# Ensure dictionary is downloaded
- ensure_dictionary_downloaded(dictionary_name)
- _ensure_mfa_server_running()
+ ensure_dictionary_downloaded(dictionary_name, conda_path=conda_path)
+ _ensure_mfa_server_running(conda_path=conda_path)
- conda = _find_conda()
+ conda = _find_conda(conda_path)
cmd = [
conda,
"run",
@@ -186,6 +222,7 @@ def run_mfa_adapt(
output_model_path,
dictionary_name="english_us_arpa",
num_iterations=1,
+ conda_path=None,
) -> None:
"""
Run MFA adapt command with the provided arguments.
@@ -196,16 +233,17 @@ def run_mfa_adapt(
output_model_path (str): Path where the adapted model will be saved.
dictionary_name (str): Name of the dictionary to use (default: "english_us_arpa").
num_iterations (int): Number of adaptation iterations.
+ conda_path (str | None): Optional user-configured path to the conda executable.
Raises:
AssertionError: If dictionary is not available.
subprocess.CalledProcessError: If MFA adaptation fails.
"""
# Ensure dictionary is downloaded
- ensure_dictionary_downloaded(dictionary_name)
- _ensure_mfa_server_running()
+ ensure_dictionary_downloaded(dictionary_name, conda_path=conda_path)
+ _ensure_mfa_server_running(conda_path=conda_path)
- conda = _find_conda()
+ conda = _find_conda(conda_path)
cmd = [
conda,
"run",
diff --git a/tests/services/__init__.py b/tests/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/services/test_mfa.py b/tests/services/test_mfa.py
new file mode 100644
index 0000000..dea7cf5
--- /dev/null
+++ b/tests/services/test_mfa.py
@@ -0,0 +1,56 @@
+"""Tests for conda executable resolution in voxkit.services.mfa.
+
+Only the pure ``_find_conda`` resolution logic is covered here; the subprocess
+wrappers shell out to MFA and conda and are exercised by integration runs.
+"""
+
+from voxkit.services import mfa
+
+
+def test_find_conda_prefers_explicit_path(tmp_path, monkeypatch):
+ """An existing explicit conda_path wins over PATH and the env var."""
+ conda = tmp_path / "conda.exe"
+ conda.write_text("")
+ # Make sure the auto-detect fast path would otherwise succeed.
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: "conda")
+ monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
+
+ assert mfa._find_conda(str(conda)) == str(conda)
+
+
+def test_find_conda_expands_user_home(tmp_path, monkeypatch):
+ """A ``~``-prefixed configured path is expanded before the existence check."""
+ conda = tmp_path / "conda.exe"
+ conda.write_text("")
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Windows home for expanduser
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: None)
+ monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
+
+ assert mfa._find_conda("~/conda.exe") == str(conda)
+
+
+def test_find_conda_uses_env_var(tmp_path, monkeypatch):
+ """VOXKIT_CONDA_PATH is honored when no explicit path is given."""
+ conda = tmp_path / "conda.exe"
+ conda.write_text("")
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: None)
+ monkeypatch.setenv("VOXKIT_CONDA_PATH", str(conda))
+
+ assert mfa._find_conda() == str(conda)
+
+
+def test_find_conda_ignores_nonexistent_configured_path(monkeypatch):
+ """A configured path that does not exist falls back to PATH detection."""
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: "conda")
+ monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
+
+ assert mfa._find_conda("/no/such/conda") == "conda"
+
+
+def test_find_conda_falls_back_to_path(monkeypatch):
+ """With no override, conda on PATH is returned."""
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: "conda")
+ monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
+
+ assert mfa._find_conda() == "conda"
From 3c2695f2c8d401146da2c1e0fda1f6c740189a23 Mon Sep 17 00:00:00 2001
From: nrgslp <53921196+nrgslp@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:31:24 -0400
Subject: [PATCH 07/26] Out-of-box setup usability fixes (#152)
* fix: add working browse button to Register New Model dialog
The Model Path field's "Browse for model directory..." placeholder was
misleading -- no QFileDialog was ever wired to it, since _create_lineedit()
just built a bare QLineEdit. Adds a FieldType.DIRPATH field type with a real
"Browse..." button (QFileDialog.getExistingDirectory) in the settings_modal
framework, applied to the Register New Model dialog. Also updates the
placeholder text and increases the dialog height so the new button row
doesn't clip the row above it.
* feat: add progress bar to processing status on pipeline pages
Adds an indeterminate QProgressBar (Containers.PROGRESS_BAR style) that
shows/hides alongside the existing status label. BaseStacker.set_status()
shows it automatically for any subclass -- covers Predict Alignments (the
MFA/align page) with no per-page changes needed there.
* feat: add progress bar and run-metadata CSV enrichment to PLLR extraction
Two related changes to the PLLR extraction page, committed together because
they touch overlapping lines (e.g. extract_pllr_logic's signature change for
metadata is adjacent to the progress-bar visibility call):
- Adds the same indeterminate progress bar as base_stacker.py's pages,
since PLLRStacker doesn't inherit BaseStacker and manages its own status
label separately.
- Appends run_datetime/corpus_name/corpus_id/engine_id/model_name/model_id
columns to every row of the phonewise/framewise output CSVs, and renames
each output file to embed corpus/engine/timestamp, so multiple runs'
outputs can coexist in the same folder instead of overwriting each other.
Handles compute_pllr() writing multiple phonewise_proba_{method}.csv
variants (a dict-returning aggregation function) rather than assuming a
single fixed filename.
* chore: rename default W2TG model display name
"prads_model" -> "default". Only affects fresh installs since
startup_routine() is gated behind is_first_launch().
* feat: show corpus/alignment filesystem locations and alignment type on Dataset Management
Nina asked for the dataset's original corpus path and each alignment's
TextGrid output path to be visible on the Dataset Management page, since
neither was surfaced anywhere in the GUI before this.
- Datasets table gains a "Location" column (dataset's original_path).
- Alignments table gains "Location" (tg_path) and "Type" columns. Type
distinguishes corrected alignments from the automatic ones they were
derived from -- without it they're visually identical (same engine,
same model). Backed by a new get_alignment_type() in
storage/alignments.py, which infers "hand"/"automatic" for older
alignments that predate the alignment_type field.
- Both Location columns elide from the left (not the default right), so
long paths show their distinguishing tail (e.g. ".../alignments//
textgrids") instead of the shared "C:\Users\..." prefix; full path
remains available via tooltip.
* fix: use directory-picker fields for dataset and hand-alignment paths
Replace the plain line-edit path inputs on the Register New Dataset dialog
with DIRPATH fields so users get a browse button, and drop the now-unused
browse_dataset_path helper. Bump the dialog height to fit the added control.
* fix: narrow DIRPATH widget to QLineEdit before wiring browse button
The browse-button handler expects a QLineEdit, but _wrap_with_browse_button
only had the widget typed as QWidget, tripping mypy. Guard with isinstance so
the type narrows and non-line-edit widgets fall through unchanged.
---------
Co-authored-by: Beckett Frey
---
src/voxkit/config/startup_config.py | 2 +-
.../gui/frameworks/settings_modal/api.py | 3 +
.../gui/frameworks/settings_modal/generic.py | 41 +++++-
.../gui/pages/datasets/datasets_page.py | 83 +++++++-----
src/voxkit/gui/pages/models/models_page.py | 6 +-
src/voxkit/gui/pages/pipeline/base_stacker.py | 26 +++-
src/voxkit/gui/pages/pipeline/pllr_stacker.py | 123 +++++++++++++++++-
src/voxkit/gui/styles/__init__.py | 13 ++
src/voxkit/storage/alignments.py | 25 +++-
tests/gui/test_datasets_page.py | 119 +++++++++++++++++
tests/storage/test_alignments.py | 27 +++-
11 files changed, 425 insertions(+), 43 deletions(-)
diff --git a/src/voxkit/config/startup_config.py b/src/voxkit/config/startup_config.py
index 334c520..2e71945 100644
--- a/src/voxkit/config/startup_config.py
+++ b/src/voxkit/config/startup_config.py
@@ -77,7 +77,7 @@ def startup_routine():
# Create folder for W2TG model
w2tg_path = storage_root / "W2TGENGINE" / MODELS_ROOT
w2tg_path.mkdir(parents=True, exist_ok=True)
- success, metadata = models.create_model("W2TGENGINE", "prads_model")
+ success, metadata = models.create_model("W2TGENGINE", "default")
if not success:
print(f"[STARTUP] Failed to create model metadata. {metadata}")
return
diff --git a/src/voxkit/gui/frameworks/settings_modal/api.py b/src/voxkit/gui/frameworks/settings_modal/api.py
index fda9148..df0d5d2 100644
--- a/src/voxkit/gui/frameworks/settings_modal/api.py
+++ b/src/voxkit/gui/frameworks/settings_modal/api.py
@@ -14,6 +14,8 @@ class FieldType(Enum):
CHECKBOX: Boolean toggle field (ToggleSwitch)
LINEEDIT: Text input field (QLineEdit)
COMBOBOX: Dropdown selection field (QComboBox)
+ DIRPATH: Text input field (QLineEdit) with a "Browse..." button that
+ opens a native directory picker and fills in the selected path.
"""
SPINBOX = "spinbox"
@@ -21,6 +23,7 @@ class FieldType(Enum):
CHECKBOX = "checkbox"
LINEEDIT = "lineedit"
COMBOBOX = "combobox"
+ DIRPATH = "dirpath"
@dataclass
diff --git a/src/voxkit/gui/frameworks/settings_modal/generic.py b/src/voxkit/gui/frameworks/settings_modal/generic.py
index f65b662..509b97d 100644
--- a/src/voxkit/gui/frameworks/settings_modal/generic.py
+++ b/src/voxkit/gui/frameworks/settings_modal/generic.py
@@ -10,6 +10,7 @@
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
+ QFileDialog,
QFormLayout,
QGraphicsBlurEffect,
QHBoxLayout,
@@ -262,7 +263,43 @@ def _create_fields(self):
for field_config in self.field_configs:
widget = self._create_field_widget(field_config)
self.field_widgets[field_config.name] = widget
- self.form_layout.addRow(field_config.label, widget)
+ row_widget = self._wrap_with_browse_button(widget, field_config)
+ self.form_layout.addRow(field_config.label, row_widget)
+
+ def _wrap_with_browse_button(self, widget: QWidget, config: FieldConfig) -> QWidget:
+ """
+ Pair a DIRPATH field's line edit with a "Browse..." button.
+
+ Args:
+ widget: The line edit widget created for this field.
+ config: Field configuration; only DIRPATH fields get a button.
+
+ Returns:
+ A container widget with the line edit and button side by side,
+ or the original widget unchanged for other field types.
+ """
+ if config.field_type != FieldType.DIRPATH or not isinstance(widget, QLineEdit):
+ return widget
+
+ container = QWidget()
+ row_layout = QHBoxLayout(container)
+ row_layout.setContentsMargins(0, 0, 0, 0)
+ row_layout.addWidget(widget)
+
+ browse_btn = QPushButton("Browse...")
+ browse_btn.setStyleSheet(Buttons.SECONDARY)
+ browse_btn.clicked.connect(lambda: self._browse_for_directory(widget))
+ row_layout.addWidget(browse_btn)
+
+ return container
+
+ def _browse_for_directory(self, lineedit: QLineEdit) -> None:
+ """Open a directory picker and write the chosen path into ``lineedit``."""
+ current = lineedit.text().strip()
+ start_dir = current if current and Path(current).exists() else str(Path.home())
+ directory = QFileDialog.getExistingDirectory(self, "Select Directory", start_dir)
+ if directory:
+ lineedit.setText(directory)
def _create_field_widget(self, config: FieldConfig) -> QWidget:
"""
@@ -290,6 +327,8 @@ def _create_field_widget(self, config: FieldConfig) -> QWidget:
widget = ToggleSwitch(checked=bool(config.default_value))
elif config.field_type == FieldType.LINEEDIT:
widget = self._create_lineedit(config)
+ elif config.field_type == FieldType.DIRPATH:
+ widget = self._create_lineedit(config)
elif config.field_type == FieldType.COMBOBOX:
widget = self._create_combobox(config)
diff --git a/src/voxkit/gui/pages/datasets/datasets_page.py b/src/voxkit/gui/pages/datasets/datasets_page.py
index 2957fdd..8d8d0ef 100644
--- a/src/voxkit/gui/pages/datasets/datasets_page.py
+++ b/src/voxkit/gui/pages/datasets/datasets_page.py
@@ -35,7 +35,7 @@
from voxkit.gui.styles import Buttons, Containers, Labels
from voxkit.gui.workers import DatasetRegistrationWorker
from voxkit.storage import alignments, datasets
-from voxkit.storage.alignments import HAND_ALIGNMENT_SENTINEL, AlignmentMetadata
+from voxkit.storage.alignments import HAND_ALIGNMENT_SENTINEL, AlignmentMetadata, get_alignment_type
from voxkit.storage.datasets import DatasetMetadata
# Virtual engine ids that are not registered in the engines registry but may
@@ -273,11 +273,12 @@ def _create_list_section(self):
# Dataset table
self.dataset_table = QTableWidget()
- self.dataset_table.setColumnCount(7)
+ self.dataset_table.setColumnCount(8)
self.dataset_table.setHorizontalHeaderLabels(
[
"Name",
"Description",
+ "Location",
"Cached",
"De-identified",
"Transcribed",
@@ -291,10 +292,14 @@ def _create_list_section(self):
if header is not None:
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
- for i in range(2, 6):
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
+ for i in range(3, 7):
header.setSectionResizeMode(i, QHeaderView.ResizeMode.ResizeToContents)
- header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
- self.dataset_table.setColumnWidth(6, 100)
+ header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed)
+ self.dataset_table.setColumnWidth(7, 100)
+ # Elide long paths (Location column) from the left so the distinguishing
+ # tail of the path stays visible instead of the shared "C:\Users\..." prefix.
+ self.dataset_table.setTextElideMode(Qt.TextElideMode.ElideLeft)
self.dataset_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.dataset_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
@@ -374,9 +379,9 @@ def _create_alignments_panel(self):
# Alignments table
self.alignments_table = QTableWidget()
- self.alignments_table.setColumnCount(5)
+ self.alignments_table.setColumnCount(7)
self.alignments_table.setHorizontalHeaderLabels(
- ["Engine", "Model", "Date Aligned", "Status", "Actions"]
+ ["Engine", "Model", "Type", "Location", "Date Aligned", "Status", "Actions"]
)
# Configure alignments table
@@ -385,9 +390,14 @@ def _create_alignments_panel(self):
align_header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
align_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
align_header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
- align_header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
- align_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
- self.alignments_table.setColumnWidth(4, 150)
+ align_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
+ align_header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
+ align_header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
+ align_header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
+ self.alignments_table.setColumnWidth(6, 150)
+ # Elide long paths (Location column) from the left so the distinguishing
+ # tail of the path stays visible instead of the shared "C:\Users\..." prefix.
+ self.alignments_table.setTextElideMode(Qt.TextElideMode.ElideLeft)
# Disable selection and editing
self.alignments_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
@@ -501,10 +511,24 @@ def _display_alignments(self, alignments: list[alignments.AlignmentMetadata]):
model_item.setFont(font)
self.alignments_table.setItem(row, 1, model_item)
+ # Type — provenance (automatic/hand/corrected), distinguishes a corrected
+ # alignment from the automatic one it was derived from, since they
+ # otherwise share the same engine/model.
+ type_item = QTableWidgetItem(get_alignment_type(alignment))
+ type_item.setFlags(type_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ self.alignments_table.setItem(row, 2, type_item)
+
+ # Location — filesystem path to the alignment's TextGrid output
+ location = alignment.get("tg_path", "Unknown")
+ location_item = QTableWidgetItem(location)
+ location_item.setFlags(location_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ location_item.setToolTip(location)
+ self.alignments_table.setItem(row, 3, location_item)
+
# Date Aligned
date_item = QTableWidgetItem(alignment["alignment_date"])
date_item.setFlags(date_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
- self.alignments_table.setItem(row, 2, date_item)
+ self.alignments_table.setItem(row, 4, date_item)
# Status
status_item = QTableWidgetItem(alignment.get("status", "Unknown"))
@@ -516,11 +540,11 @@ def _display_alignments(self, alignments: list[alignments.AlignmentMetadata]):
status_item.setForeground(Qt.GlobalColor.darkYellow)
elif status == "failed":
status_item.setForeground(Qt.GlobalColor.red)
- self.alignments_table.setItem(row, 3, status_item)
+ self.alignments_table.setItem(row, 4, status_item)
# Actions
actions_widget = self._create_alignment_action_buttons(alignment)
- self.alignments_table.setCellWidget(row, 4, actions_widget)
+ self.alignments_table.setCellWidget(row, 5, actions_widget)
# Disconnect old connections and connect cell click for model column
try:
@@ -626,16 +650,16 @@ def open_registration_dialog(self):
# Create settings config
config = SettingsConfig(
title="Register New Dataset",
- dimensions=(500, 400),
+ dimensions=(500, 460),
apply_blur=False, # Disable blur to avoid parent issues
store_file="dataset_registration_settings.json",
fields=[
FieldConfig(
name="dataset_path",
label="Dataset Path",
- field_type=FieldType.LINEEDIT,
+ field_type=FieldType.DIRPATH,
default_value="",
- placeholder="Browse for dataset directory...",
+ placeholder="e.g., /home/corpora/timit_train",
tooltip="Root directory containing speaker subdirectories",
),
FieldConfig(
@@ -686,9 +710,9 @@ def open_registration_dialog(self):
FieldConfig(
name="hand_alignments_path",
label="Hand Alignments Path",
- field_type=FieldType.LINEEDIT,
+ field_type=FieldType.DIRPATH,
default_value="",
- placeholder="Optional: path to pre-existing TextGrids...",
+ placeholder="Optional: directory of pre-existing TextGrids",
tooltip="Optional directory containing hand-annotated TextGrid files",
),
],
@@ -759,13 +783,6 @@ def process_registration(self, values: dict):
self.registration_worker.finished.connect(self.registration_complete)
self.registration_worker.start()
- def browse_dataset_path(self):
- """Open directory picker for dataset path"""
- directory = QFileDialog.getExistingDirectory(self, "Select Dataset Root Directory")
- if directory:
- return directory
- return None
-
def show_progress(self, message):
"""Show progress message"""
print(message)
@@ -806,29 +823,35 @@ def refresh_datasets(self):
desc_item.setToolTip(meta["description"])
self.dataset_table.setItem(index, 1, desc_item)
+ # Location — original corpus filesystem path (always recorded, even when cached)
+ location = meta.get("original_path", "Unknown")
+ location_item = QTableWidgetItem(location)
+ location_item.setToolTip(location)
+ self.dataset_table.setItem(index, 2, location_item)
+
# Cached
self.dataset_table.setItem(
- index, 2, QTableWidgetItem("Yes" if meta["cached"] else "No")
+ index, 3, QTableWidgetItem("Yes" if meta["cached"] else "No")
)
# Anonymized
self.dataset_table.setItem(
- index, 3, QTableWidgetItem("Yes" if meta["anonymize"] else "No")
+ index, 4, QTableWidgetItem("Yes" if meta["anonymize"] else "No")
)
self.dataset_table.setItem(
- index, 4, QTableWidgetItem("Yes" if meta.get("transcribed", False) else "No")
+ index, 5, QTableWidgetItem("Yes" if meta.get("transcribed", False) else "No")
)
# Registration date
reg_date = meta.get("registration_date", "Unknown")
if reg_date != "Unknown":
reg_date = reg_date.split("T")[0] # Show only date part
- self.dataset_table.setItem(index, 5, QTableWidgetItem(reg_date))
+ self.dataset_table.setItem(index, 6, QTableWidgetItem(reg_date))
# Actions - Details button
actions_widget = self._create_dataset_action_buttons(meta)
- self.dataset_table.setCellWidget(index, 6, actions_widget)
+ self.dataset_table.setCellWidget(index, 7, actions_widget)
def _create_dataset_action_buttons(self, dataset_meta: DatasetMetadata):
"""Create action buttons for a dataset row.
diff --git a/src/voxkit/gui/pages/models/models_page.py b/src/voxkit/gui/pages/models/models_page.py
index 83dd377..1294823 100644
--- a/src/voxkit/gui/pages/models/models_page.py
+++ b/src/voxkit/gui/pages/models/models_page.py
@@ -193,16 +193,16 @@ def open_registration_dialog(self):
# Create settings config
config = SettingsConfig(
title="Register New Model",
- dimensions=(400, 250),
+ dimensions=(400, 320),
apply_blur=False,
store_file="model_registration_settings.json",
fields=[
FieldConfig(
name="model_path",
label="Model Path",
- field_type=FieldType.LINEEDIT,
+ field_type=FieldType.DIRPATH,
default_value="",
- placeholder="Browse for model directory...",
+ placeholder="e.g., /home/acoustic-models",
tooltip="Path to the model directory or file",
),
FieldConfig(
diff --git a/src/voxkit/gui/pages/pipeline/base_stacker.py b/src/voxkit/gui/pages/pipeline/base_stacker.py
index e1227ef..6117ffd 100644
--- a/src/voxkit/gui/pages/pipeline/base_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/base_stacker.py
@@ -8,9 +8,16 @@
"""
from PyQt6.QtCore import Qt
-from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
+from PyQt6.QtWidgets import (
+ QHBoxLayout,
+ QLabel,
+ QProgressBar,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
-from voxkit.gui.styles import Buttons, Labels
+from voxkit.gui.styles import Buttons, Containers, Labels
class BaseStacker(QWidget):
@@ -40,6 +47,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._parent_widget = parent
self.status_label: QLabel | None = None
+ self.progress_bar: QProgressBar | None = None
self.main_layout: QVBoxLayout
self.content_layout: QVBoxLayout
self.init_ui()
@@ -92,12 +100,21 @@ def _create_header(self):
self.main_layout.addLayout(header_layout)
def _create_status_label(self):
- """Create the standard status label."""
+ """Create the standard status label and its indeterminate progress bar."""
self.status_label = QLabel("Ready")
self.status_label.setStyleSheet(Labels.STATUS_READY)
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.main_layout.addWidget(self.status_label)
+ # Indeterminate (busy) bar: range (0, 0) makes Qt render a bouncing
+ # bar instead of a percentage, since work here has no known progress.
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setRange(0, 0)
+ self.progress_bar.setTextVisible(False)
+ self.progress_bar.setStyleSheet(Containers.PROGRESS_BAR)
+ self.progress_bar.setVisible(False)
+ self.main_layout.addWidget(self.progress_bar)
+
def set_status(self, message: str, status_type: str = "ready"):
"""Set the status label text and styling.
@@ -118,6 +135,9 @@ def set_status(self, message: str, status_type: str = "ready"):
self.status_label.setText(message)
self.status_label.setStyleSheet(status_styles.get(status_type, Labels.STATUS_READY))
+ if self.progress_bar:
+ self.progress_bar.setVisible(status_type == "working")
+
# Methods for subclasses to override
def build_ui(self):
diff --git a/src/voxkit/gui/pages/pipeline/pllr_stacker.py b/src/voxkit/gui/pages/pipeline/pllr_stacker.py
index 0714435..e4f5c24 100644
--- a/src/voxkit/gui/pages/pipeline/pllr_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/pllr_stacker.py
@@ -14,6 +14,9 @@
- Outputs phonewise and framewise probability CSVs
"""
+import csv
+import re
+from datetime import datetime
from pathlib import Path
from pypllrcomputer import compute_pllr
@@ -25,6 +28,7 @@
QLabel,
QLineEdit,
QMessageBox,
+ QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
@@ -110,6 +114,66 @@ def get_pllr_settings_config() -> SettingsConfig:
)
+def _append_run_metadata_to_csv(csv_path: str, run_metadata: dict[str, str]) -> None:
+ """Append run metadata columns to every row of a GOP output CSV, in place.
+
+ Why: compute_pllr() (external pypllrcomputer package) writes phonewise/
+ framewise CSVs with no provenance info, making it impossible to trace a
+ results file back to the corpus, engine, and model that produced it once
+ it's been moved or shared. Appending columns here avoids touching the
+ external package.
+
+ Args:
+ csv_path: Path to the CSV file to enrich. No-op if it doesn't exist.
+ run_metadata: Column name -> value pairs appended to every row.
+ """
+ path = Path(csv_path)
+ if not path.exists():
+ return
+
+ with open(path, "r", encoding="utf-8", newline="") as f:
+ rows = list(csv.reader(f))
+
+ if not rows:
+ return
+
+ header, *data_rows = rows
+ metadata_columns = list(run_metadata.keys())
+ metadata_values = [run_metadata[key] for key in metadata_columns]
+
+ with open(path, "w", encoding="utf-8", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerow(header + metadata_columns)
+ writer.writerows(row + metadata_values for row in data_rows)
+
+
+def _sanitize_filename_part(text: str) -> str:
+ """Make a string safe to embed in a filename across platforms."""
+ cleaned = re.sub(r"[^A-Za-z0-9_-]+", "_", text.strip())
+ return cleaned.strip("_") or "unknown"
+
+
+def _rename_with_run_metadata(
+ csv_path: str, corpus_name: str, engine_id: str, date_stamp: str
+) -> str:
+ """Rename a GOP output CSV to embed corpus/engine/date, returning the new path.
+
+ Why: the appended metadata columns identify a file's provenance once
+ opened, but don't help distinguish files at a glance in a file browser
+ when working across multiple corpora, engines, or runs.
+ """
+ path = Path(csv_path)
+ if not path.exists():
+ return csv_path
+
+ suffix = "_".join(
+ _sanitize_filename_part(part) for part in (corpus_name, engine_id, date_stamp)
+ )
+ new_path = path.with_name(f"{path.stem}_{suffix}{path.suffix}")
+ path.rename(new_path)
+ return str(new_path)
+
+
class PLLRStacker(QWidget):
"""PLLR extraction pipeline page.
@@ -299,6 +363,15 @@ def init_ui(self):
self.extract_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.extract_status)
+ # Indeterminate (busy) bar: range (0, 0) makes Qt render a bouncing
+ # bar instead of a percentage, since work here has no known progress.
+ self.extract_progress = QProgressBar()
+ self.extract_progress.setRange(0, 0)
+ self.extract_progress.setTextVisible(False)
+ self.extract_progress.setStyleSheet(Containers.PROGRESS_BAR)
+ self.extract_progress.setVisible(False)
+ layout.addWidget(self.extract_progress)
+
layout.addStretch()
return self
@@ -429,19 +502,24 @@ def on_extract_pllr(self):
# Update UI
self.extract_status.setText("Processing...")
self.extract_status.setStyleSheet("color: #f39c12; font-size: 12px; margin-top: 5px;")
+ self.extract_progress.setVisible(True)
self.extract_btn.setEnabled(False)
print("[DEBUG] Starting worker thread...")
# Start worker thread
self.worker = WorkerThread(
- lambda: self.extract_pllr_logic(textgrid_path, wavlab_path, output_path)
+ lambda: self.extract_pllr_logic(
+ textgrid_path, wavlab_path, output_path, dataset_meta, alignment_data
+ )
)
self.worker.finished.connect(self.on_extract_finished)
self.worker.start()
print("[DEBUG] Worker thread started")
- def extract_pllr_logic(self, textgrid_path, wavlab_path, output_path):
+ def extract_pllr_logic(
+ self, textgrid_path, wavlab_path, output_path, dataset_meta=None, alignment_data=None
+ ):
"""Actual PLLR extraction logic"""
print("\n=== EXTRACT PLLR LOGIC ===")
@@ -537,6 +615,46 @@ def extract_pllr_logic(self, textgrid_path, wavlab_path, output_path):
aggregation_function=agg_fn,
)
print("[LOGIC] compute_pllr() completed successfully")
+
+ model_metadata = (alignment_data or {}).get("model_metadata") or {}
+ now = datetime.now()
+ run_metadata = {
+ "run_datetime": now.isoformat(timespec="seconds"),
+ "corpus_name": (dataset_meta or {}).get("name", ""),
+ "corpus_id": (dataset_meta or {}).get("id", ""),
+ "engine_id": (alignment_data or {}).get("engine_id", ""),
+ "model_name": model_metadata.get("name", ""),
+ "model_id": model_metadata.get("id", ""),
+ }
+ # Full timestamp (not just date) so multiple runs on the same day
+ # against the same corpus/engine never collide on rename below.
+ date_stamp = now.strftime("%Y%m%d_%H%M%S")
+
+ # compute_pllr() writes phonewise_path verbatim only when the
+ # aggregation function returns a single DataFrame. Aggregations
+ # like aggregate_by_phoneme_occurrence return a dict of
+ # per-statistic DataFrames instead, written to
+ # "{stem}_{method}.csv" (e.g. phonewise_proba_mean.csv) — so glob
+ # for every variant rather than assuming the literal filename.
+ phonewise_stem = Path(phonewise_path).stem
+ phonewise_outputs = list(Path(phonewise_path).parent.glob(f"{phonewise_stem}*.csv"))
+ if not phonewise_outputs:
+ print(f"[WARN] No phonewise output CSVs found matching {phonewise_stem}*.csv")
+
+ output_paths = [*phonewise_outputs, Path(framewise_path)]
+ renamed_paths = []
+ for csv_path in output_paths:
+ _append_run_metadata_to_csv(str(csv_path), run_metadata)
+ renamed_paths.append(
+ _rename_with_run_metadata(
+ str(csv_path),
+ run_metadata["corpus_name"],
+ run_metadata["engine_id"],
+ date_stamp,
+ )
+ )
+ print(f"[LOGIC] Appended run metadata and renamed output CSVs: {renamed_paths}")
+
return "PLLR extracted successfully"
except Exception as e:
print(f"[ERROR] Exception in compute_pllr(): {type(e).__name__}")
@@ -553,6 +671,7 @@ def on_extract_finished(self, success, message):
print(f"[FINISHED] Message: {message}")
self.extract_btn.setEnabled(True)
+ self.extract_progress.setVisible(False)
if success:
print("[FINISHED] Extraction completed successfully")
diff --git a/src/voxkit/gui/styles/__init__.py b/src/voxkit/gui/styles/__init__.py
index 4db9929..0ed27c8 100644
--- a/src/voxkit/gui/styles/__init__.py
+++ b/src/voxkit/gui/styles/__init__.py
@@ -637,6 +637,19 @@ class Containers:
}
"""
+ PROGRESS_BAR = f"""
+ QProgressBar {{
+ border: 1px solid {Colors.BORDER};
+ border-radius: 4px;
+ background-color: {Colors.BG_SECONDARY};
+ max-height: 6px;
+ }}
+ QProgressBar::chunk {{
+ background-color: {Colors.PRIMARY};
+ border-radius: 4px;
+ }}
+ """
+
GROUP_BOX = """
QGroupBox {
font-weight: bold;
diff --git a/src/voxkit/storage/alignments.py b/src/voxkit/storage/alignments.py
index dd1cf9a..c9b260a 100644
--- a/src/voxkit/storage/alignments.py
+++ b/src/voxkit/storage/alignments.py
@@ -19,6 +19,7 @@
- **create_alignment**: Create a new alignment entry in storage
- **create_hand_alignment**: Create a new hand-annotated alignment entry in storage
- **get_alignment_metadata**: Retrieve metadata for a specific alignment
+- **get_alignment_type**: Return an alignment's provenance (automatic/hand/corrected)
- **update_alignment**: Update the status or details of an existing alignment
- **list_alignments**: List all alignments for a given dataset
- **delete_alignment**: Remove an alignment from storage
@@ -36,7 +37,7 @@
import os
import shutil
from pathlib import Path
-from typing import List, Literal, Tuple, TypedDict
+from typing import List, Literal, NotRequired, Tuple, TypedDict
from .constants import ALIGNMENTS_ROOT, SUPERSET_AUDIO_EXTENSIONS
from .datasets import _get_dataset_root, get_dataset_metadata
@@ -56,6 +57,10 @@
"""
+AlignmentType = Literal["automatic", "hand", "corrected"]
+"""Provenance of an alignment: model-generated, hand-annotated, or user-corrected."""
+
+
class AlignmentMetadata(TypedDict):
"""Alignment metadata structure.
@@ -67,6 +72,9 @@ class AlignmentMetadata(TypedDict):
alignment_date: Human-readable alignment creation timestamp.
status: Current status of the alignment operation.
tg_path: Path to the directory containing TextGrid output files.
+ alignment_type: Provenance of the alignment. Absent on alignments created
+ before this field existed -- use `get_alignment_type()` rather than
+ reading this key directly.
"""
id: str
@@ -76,6 +84,21 @@ class AlignmentMetadata(TypedDict):
alignment_date: str
status: AlignmentStatus
tg_path: str
+ alignment_type: NotRequired[AlignmentType]
+
+
+def get_alignment_type(meta: AlignmentMetadata) -> str:
+ """Return the alignment's type, inferring it for alignments predating this field.
+
+ Alignments written before `alignment_type` existed don't have the key in their
+ on-disk JSON at all -- infer "hand" from the legacy `engine_id` sentinel and
+ otherwise default to "automatic".
+ """
+ if "alignment_type" in meta:
+ return meta["alignment_type"]
+ if meta.get("engine_id") == HAND_ALIGNMENT_SENTINEL:
+ return "hand"
+ return "automatic"
def _get_alignments_root(dataset_id: str) -> Path | None:
diff --git a/tests/gui/test_datasets_page.py b/tests/gui/test_datasets_page.py
index 3ea5d12..acf5669 100644
--- a/tests/gui/test_datasets_page.py
+++ b/tests/gui/test_datasets_page.py
@@ -48,3 +48,122 @@ def test_populated_state_shows_table_and_hides_label(self, qtbot, datasets_page)
assert datasets_page.empty_label.isHidden()
assert not datasets_page.dataset_table.isHidden()
assert datasets_page.dataset_table.rowCount() == 1
+
+ def test_location_column_shows_original_path(self, qtbot, datasets_page):
+ sample_metadata = [
+ {
+ "id": "ds-1",
+ "name": "Test Dataset",
+ "description": "A test dataset",
+ "original_path": r"C:\corpora\timit_train",
+ "cached": True,
+ "anonymize": False,
+ "transcribed": False,
+ "registration_date": "2024-01-01T00:00:00",
+ }
+ ]
+ with patch(
+ "voxkit.gui.pages.datasets.datasets_page.datasets.list_datasets_metadata",
+ return_value=sample_metadata,
+ ):
+ datasets_page.refresh_datasets()
+
+ location_item = datasets_page.dataset_table.item(0, 2)
+ assert location_item.text() == r"C:\corpora\timit_train"
+ assert location_item.toolTip() == r"C:\corpora\timit_train"
+
+ def test_location_column_falls_back_when_missing(self, qtbot, datasets_page):
+ sample_metadata = [
+ {
+ "id": "ds-1",
+ "name": "Test Dataset",
+ "description": "A test dataset",
+ "cached": False,
+ "anonymize": False,
+ "transcribed": False,
+ "registration_date": "2024-01-01T00:00:00",
+ }
+ ]
+ with patch(
+ "voxkit.gui.pages.datasets.datasets_page.datasets.list_datasets_metadata",
+ return_value=sample_metadata,
+ ):
+ datasets_page.refresh_datasets()
+
+ assert datasets_page.dataset_table.item(0, 2).text() == "Unknown"
+
+
+class TestDisplayAlignments:
+ def test_location_column_shows_tg_path(self, qtbot, datasets_page):
+ sample_alignment = {
+ "id": "align-1",
+ "engine_id": "mfa",
+ "model_metadata": {"id": "model-1", "name": "Test Model"},
+ "alignment_date": "2024-01-01T00:00:00",
+ "status": "completed",
+ "tg_path": r"C:\voxkit_storage\datasets\ds-1\alignments\align-1\textgrids",
+ }
+
+ datasets_page._display_alignments([sample_alignment])
+
+ location_item = datasets_page.alignments_table.item(0, 3)
+ assert location_item.text() == sample_alignment["tg_path"]
+ assert location_item.toolTip() == sample_alignment["tg_path"]
+
+ def test_location_column_falls_back_when_missing(self, qtbot, datasets_page):
+ sample_alignment = {
+ "id": "align-1",
+ "engine_id": "mfa",
+ "model_metadata": {"id": "model-1", "name": "Test Model"},
+ "alignment_date": "2024-01-01T00:00:00",
+ "status": "completed",
+ }
+
+ datasets_page._display_alignments([sample_alignment])
+
+ assert datasets_page.alignments_table.item(0, 3).text() == "Unknown"
+
+ def test_type_column_shows_corrected_for_corrected_alignment(self, qtbot, datasets_page):
+ sample_alignment = {
+ "id": "align-2",
+ "engine_id": "MFAENGINE",
+ "model_metadata": {"id": "model-1", "name": "Test Model"},
+ "alignment_date": "2024-01-01T00:00:00",
+ "status": "completed",
+ "tg_path": r"C:\voxkit_storage\datasets\ds-1\alignments\align-2\textgrids",
+ "source_alignment_id": "align-1",
+ "alignment_type": "corrected",
+ }
+
+ datasets_page._display_alignments([sample_alignment])
+
+ assert datasets_page.alignments_table.item(0, 2).text() == "corrected"
+
+ def test_type_column_defaults_to_automatic_for_legacy_alignment(self, qtbot, datasets_page):
+ """Alignments predating the alignment_type field should read as "automatic"."""
+ sample_alignment = {
+ "id": "align-1",
+ "engine_id": "MFAENGINE",
+ "model_metadata": {"id": "model-1", "name": "Test Model"},
+ "alignment_date": "2024-01-01T00:00:00",
+ "status": "completed",
+ "tg_path": r"C:\voxkit_storage\datasets\ds-1\alignments\align-1\textgrids",
+ }
+
+ datasets_page._display_alignments([sample_alignment])
+
+ assert datasets_page.alignments_table.item(0, 2).text() == "automatic"
+
+ def test_type_column_shows_hand_for_hand_alignment_sentinel(self, qtbot, datasets_page):
+ sample_alignment = {
+ "id": "hand",
+ "engine_id": "hand",
+ "model_metadata": {"id": "hand", "name": "hand"},
+ "alignment_date": "2024-01-01T00:00:00",
+ "status": "completed",
+ "tg_path": r"C:\voxkit_storage\datasets\ds-1\alignments\hand\textgrids",
+ }
+
+ datasets_page._display_alignments([sample_alignment])
+
+ assert datasets_page.alignments_table.item(0, 2).text() == "hand"
diff --git a/tests/storage/test_alignments.py b/tests/storage/test_alignments.py
index f65e79e..e58af5b 100644
--- a/tests/storage/test_alignments.py
+++ b/tests/storage/test_alignments.py
@@ -107,7 +107,7 @@ def test_create_alignment_success(self, monkeypatch, sample_dataset, sample_mode
assert isinstance(result, dict)
# Verify all required keys are present
- required_keys = set(AlignmentMetadata.__annotations__.keys())
+ required_keys = set(AlignmentMetadata.__required_keys__)
assert required_keys.issubset(set(result.keys()))
# Verify field values
@@ -192,7 +192,7 @@ def test_create_alignment_non_cached_dataset(self, monkeypatch, sample_model):
assert isinstance(result, dict)
# Verify all required keys are present
- required_keys = set(AlignmentMetadata.__annotations__.keys())
+ required_keys = set(AlignmentMetadata.__required_keys__)
assert required_keys.issubset(set(result.keys()))
# Verify field values
@@ -591,3 +591,26 @@ def test_get_alignment_metadata_normalizes_status(
assert fetched_metadata is not None
assert fetched_metadata["status"] == "failed" # Should be lowercase
+
+
+class TestGetAlignmentType:
+ """get_alignment_type() must read the recorded field, and infer for legacy data
+ that predates the alignment_type field entirely."""
+
+ def test_returns_recorded_type(self):
+ from voxkit.storage.alignments import get_alignment_type
+
+ meta = {"engine_id": "MFAENGINE", "alignment_type": "corrected"}
+ assert get_alignment_type(meta) == "corrected"
+
+ def test_infers_hand_from_legacy_sentinel(self):
+ from voxkit.storage.alignments import HAND_ALIGNMENT_SENTINEL, get_alignment_type
+
+ meta = {"engine_id": HAND_ALIGNMENT_SENTINEL}
+ assert get_alignment_type(meta) == "hand"
+
+ def test_defaults_to_automatic_for_legacy_data(self):
+ from voxkit.storage.alignments import get_alignment_type
+
+ meta = {"engine_id": "MFAENGINE"}
+ assert get_alignment_type(meta) == "automatic"
From 298a0e87c78320d6495c9ee32811964403762b95 Mon Sep 17 00:00:00 2001
From: nrgslp <53921196+nrgslp@users.noreply.github.com>
Date: Tue, 28 Jul 2026 11:17:07 -0400
Subject: [PATCH 08/26] feat: bake MFA/conda setup into the app via bundled
micromamba (#151)
* feat: bake MFA setup into the app via a bundled micromamba environment
VoxKit previously required a user to manually install conda and run
`conda create -n aligner -c conda-forge montreal-forced-aligner` in a
terminal -- a recipe documented nowhere in the repo except inside a
FileNotFoundError message. Most users (SLP researchers, not engineers)
should never have to do this.
Ships a vendored static micromamba binary (vendor/micromamba/) plus a
pinned, explicit lockfile (config/mfa-env/aligner-win-64.lock) for an
environment equivalent to the manual recipe above. On first launch,
config/startup_config.py provisions VoxKit's own managed "aligner"
environment at ~/.voxkit/mfa-env via `micromamba create --file `
-- a fast, solve-free, resumable install of pinned package URLs, fully
invisible to the user beyond the existing first-run loading dialog.
services/mfa.py's four subprocess call sites (run_mfa_align, run_mfa_adapt,
ensure_dictionary_downloaded, _ensure_mfa_server_running) now share a new
_mfa_invocation() helper that prefers this bundled environment when ready,
falling back to exactly the previous conda_path/_find_conda + "conda run -n
aligner" mechanism otherwise -- zero behavior change for anyone with their
own pre-existing conda + aligner setup.
Real-world testing while building this (documented in docs/BUILD.md)
surfaced three non-obvious issues the implementation works around:
- The `mfa`/`mfa.exe` entry-point stub can fail to launch on Windows even
inside a correctly-activated environment; invoking `python
/Scripts/mfa-script.py` directly works reliably where the stub does
not.
- MFA's global config directory (~/Documents/MFA by default) must be
isolated per-environment via MFA_ROOT_DIR, or a version mismatch between
a pre-existing user setup and the bundled environment can make
global_config.yaml unreadable.
- PostgreSQL's Unix-domain-socket path has a hard 107-byte limit, which a
deeply nested MFA_ROOT_DIR can exceed and silently break `mfa server
init` -- ~/.voxkit/mfa-root is short enough to be safe.
Also adds a "Repair/Reinstall MFA Environment" button to Generate
Alignments as a manual retry path, since first-run provisioning failures
aren't automatically retried on next launch (to avoid blocking W2TG-only
usage or the rest of first-run setup).
Reviewed with Fable (a second model) before implementation: considered
true bundling via conda-constructor/conda-pack (rejected as a
disproportionate ongoing burden -- multi-GB installer, per-OS release CI
that doesn't exist today, Kaldi/Postgres relocation risk, no known prior
art) versus this micromamba + pinned-lockfile approach, which delivers
most of the same "never touch a terminal" UX at a fraction of the
build/release complexity.
* fix: silence mypy attr-defined error on sys._MEIPASS
mfa_provision.py isn't in pyproject.toml's mypy exclude list (unlike
main.py, which uses the identical frozen-bundle pattern but is
excluded), so mypy correctly flags sys._MEIPASS as an unknown
attribute -- it's only set by PyInstaller at runtime, not part of the
stdlib sys module stubs. Add a targeted type: ignore rather than
excluding the whole file, since the rest of the module should stay
type-checked.
* fix: pin kaldi to a CPU build in the bundled MFA environment lockfile
The lockfile pinned kaldi-5.5.1172-cuda129hf00e934_5, a CUDA build whose
kaldi-cudamatrix.dll links against nvcuda.dll. That DLL ships with the
NVIDIA display driver and never in a conda package, so on any machine
without an NVIDIA GPU the provisioned environment failed to import:
ImportError: DLL load failed while importing _kalpy:
The specified module could not be found.
This broke every mfa invocation, surfacing to users as a failed
english_us_arpa dictionary download.
conda-forge ships both CPU and CUDA variants of kaldi at the same
version, and the solver picks between them via the __cuda virtual
package -- i.e. based on whether the machine that generated the lockfile
had an NVIDIA driver. The shipped installer was therefore broken for
every non-NVIDIA user and worked on NVIDIA machines only by coincidence.
Pin kaldi=*=cpu*, which resolves to kaldi-5.5.1172-cpu_hf03c2bf_5 --
identical version and build number, so no functional change beyond
dropping CUDA. The CUDA variant bought us nothing regardless: kaldi's
CUDA components accelerate nnet3 online decoding, while align/adapt are
GMM-based and CPU-only. MFA's alignment/ and acoustic_modeling/ packages
contain zero CUDA references; the only CUDA use in MFA is torch-based,
in diarization/transcription/vad, none of which VoxKit invokes.
Also drops 12 CUDA packages (libcublas, libcusolver, libmagma, ...)
from the installer.
Document the pin in BUILD.md, since the regeneration command there is
what introduced this, and add two validation steps: grep the lockfile
for CUDA packages, and import _kalpy explicitly. The latter matters
because `mfa version` still passes with a CUDA build on a GPU machine,
so the previous validation could not catch this on the machine that
generated the bad lockfile.
* ci: bump setup-uv cache-suffix to invalidate corrupted git cache
The uv git cache restored by setup-uv was missing objects, causing
`git clone` from the local cache db to fail with "empty repository" /
"failed to copy file ... No such file or directory" when building the
pypllrcomputer git dependency. Bump cache-suffix to v2 to force a fresh
cache across all workflows.
---------
Co-authored-by: beckett
Co-authored-by: Beckett Frey
---
.github/workflows/code-quality.yml | 1 +
.github/workflows/sync-docs.yml | 1 +
.github/workflows/tests-macos.yml | 1 +
.github/workflows/tests-ubuntu.yml | 1 +
.github/workflows/tests-windows.yml | 1 +
config/mfa-env/aligner-win-64.lock | 220 ++++++++++++++++++
docs/BUILD.md | 175 ++++++++++++++
scripts/build.py | 7 +
src/voxkit/config/startup_config.py | 15 ++
.../gui/pages/pipeline/prediction_stacker.py | 47 ++++
src/voxkit/services/mfa.py | 120 ++++++----
src/voxkit/services/mfa_provision.py | 122 ++++++++++
tests/services/test_mfa.py | 52 +++++
tests/services/test_mfa_provision.py | 99 ++++++++
vendor/micromamba/micromamba.exe | Bin 0 -> 11311104 bytes
15 files changed, 814 insertions(+), 48 deletions(-)
create mode 100644 config/mfa-env/aligner-win-64.lock
create mode 100644 docs/BUILD.md
create mode 100644 src/voxkit/services/mfa_provision.py
create mode 100644 tests/services/test_mfa_provision.py
create mode 100644 vendor/micromamba/micromamba.exe
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 7e0c5de..7c37738 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -26,6 +26,7 @@ jobs:
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
+ cache-suffix: v2
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml
index 422bebb..2141864 100644
--- a/.github/workflows/sync-docs.yml
+++ b/.github/workflows/sync-docs.yml
@@ -17,6 +17,7 @@ jobs:
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
+ cache-suffix: v2
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 4db3fbc..d11e4d5 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -25,6 +25,7 @@ jobs:
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
+ cache-suffix: v2
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index 2126f5d..0a888cb 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -48,6 +48,7 @@ jobs:
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
+ cache-suffix: v2
- name: Configure Git for private repos
run: |
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index edbe030..4d47825 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -25,6 +25,7 @@ jobs:
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
+ cache-suffix: v2
- name: Configure Git for private repos
run: |
diff --git a/config/mfa-env/aligner-win-64.lock b/config/mfa-env/aligner-win-64.lock
new file mode 100644
index 0000000..644118e
--- /dev/null
+++ b/config/mfa-env/aligner-win-64.lock
@@ -0,0 +1,220 @@
+# This file may be used to create an environment using:
+# $ conda create --name --file
+# platform: win-64
+@EXPLICIT
+https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda#71b24316859acd00bdb8b38f5e2ce328
+https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda#8a86073cf3b343b87d03f41790d8b4e5
+https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda#3b2b211930103e49d77da1a7d9ea9af9
+https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda#1626967b574d1784b578b52eaeb071e7
+https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda#8b53a83fda40ec679e4d63fa32fae989
+https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda#06a5bf5a1ca16cce0df6eaa91fc42bc2
+https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda#2eacea63f545b97342da520df6854276
+https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda#4cb8e6b48f67de0b018719cdf1136306
+https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda#ccc490c81ffe14181861beac0e8f3169
+https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda#720b39f5ec0610457b725eb3f396219a
+https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda#8f83619ab1588b98dd99c90b0bfc5c6d
+https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda#e4a9fc2bba3b022dad998c78856afe47
+https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda#ca0d59f40a02a15e9b5d0ff8db0f85e3
+https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda#dbabbd6234dea34040e631f87676292f
+https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda#e27d2ac27b096dc51fedfcf775a53f9b
+https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda#e99f95734a326c0fd4d02bbd995150d4
+https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8
+https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda#aaf79e2af50a151fb5b5a3e3f38b7a69
+https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda#fcb489df604d100968b737f2cb6076c6
+https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda#12e0de38e6bb7f7745ec0d19a20b8270
+https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda#22ff6a23190a29024b0df04b4caa0c66
+https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda#200323d73f85b9c5c411db8c8c4942db
+https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda#3845f3d75991bae0fb90884662f4327c
+https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda#c70ad746c22219b9700931707482992c
+https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab
+https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda#577b04680ae422adb86fc60d7b940659
+https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda#fb568fbae6908ba86a090a85a089d11f
+https://conda.anaconda.org/conda-forge/win-64/aom-3.14.1-pl5321h06fc181_1.conda#7a8ace8100a48355a34d87386012c57b
+https://conda.anaconda.org/conda-forge/win-64/audioop-lts-0.2.2-py313h5fd188c_2.conda#926e229a902e76a30ceab120e83e2e61
+https://conda.anaconda.org/conda-forge/win-64/audioread-3.0.1-py313hfa70ccb_3.conda#f90d01b48c2ae05f2c90f28d6b60e9e1
+https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda#053b84beec00b71ea8ff7a4f84b55207
+https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py313h2a31948_0.conda#144ae232f6f920307f4aadc088137589
+https://conda.anaconda.org/conda-forge/win-64/dlfcn-win32-1.4.2-hac47afa_0.conda#3a1ea992fd445a5d8c7f27648eff2b5f
+https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda#e596942e8ee6ee17fdcf1e6a77757a66
+https://conda.anaconda.org/conda-forge/win-64/openfst-1.8.4-hc790b64_1.conda#70bdba93eba9782b2b711e9e3b0db74b
+https://conda.anaconda.org/conda-forge/win-64/baumwelch-0.3.11-hd0849ee_1.conda#c69773b7e64ec002b337197a9bd5c471
+https://conda.anaconda.org/conda-forge/win-64/libbrotlicommon-1.2.0-hfd05255_1.conda#444b0a45bbd1cb24f82eedb56721b9c4
+https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.2.0-hfd05255_1.conda#450e3ae947fc46b60f1d8f8f318b40d4
+https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.2.0-hfd05255_1.conda#ccd93cfa8e54fd9df4e83dbe55ff6e8c
+https://conda.anaconda.org/conda-forge/win-64/brotli-bin-1.2.0-hfd05255_1.conda#6abd7089eb3f0c790235fe469558d190
+https://conda.anaconda.org/conda-forge/win-64/brotli-1.2.0-h2d644bc_1.conda#bc58fdbced45bb096364de0fba1637af
+https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py313h3ebfc14_1.conda#916a39a0261621b8c33e9db2366dd427
+https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.58-h7351971_0.conda#52f1280563f3b48b5f75414cd2d15dd1
+https://conda.anaconda.org/conda-forge/win-64/libfreetype6-2.14.3-hdbac1cb_1.conda#4e4d54f9f98383d977ba56ef39ebf46d
+https://conda.anaconda.org/conda-forge/win-64/libfreetype-2.14.3-h57928b3_1.conda#e45b52fb9a81c9e2708465a706e05952
+https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda#64571d1dd6cdcfa25d0664a5950fdaa2
+https://conda.anaconda.org/conda-forge/win-64/libintl-0.22.5-h5728263_3.conda#2cf0cf76cc15d360dfa2f17fd6cf9772
+https://conda.anaconda.org/conda-forge/win-64/fontconfig-2.18.2-hd47e2ca_0.conda#8a2cb80ec7f2a366dd53b48403844154
+https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2#0c96522c6bdaed4b1566d11387caaf45
+https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2#34893075a5c9e55cdafac56607368fc6
+https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2#4d59c254e01d9cde7957100457e2d5fb
+https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda#49023d73832ef61042f6a237cb2687e7
+https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda#a7970cd949a077b7cb9696379d338681
+https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2#fee5683a3f04bd15cbd8318b096a27ab
+https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-hd2b5f0e_0.conda#77eaf2336f3ae749e712f63e36b0f0a1
+https://conda.anaconda.org/conda-forge/win-64/libglib-2.88.2-h7ce1215_0.conda#5be116480ef34a5646894d7f7cd7ae41
+https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_2.conda#df8da7fe89bdc91b880df69a8eb1c37b
+https://conda.anaconda.org/conda-forge/win-64/cairo-1.18.4-h477c42c_1.conda#52ea1beba35b69852d210242dd20f97d
+https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda#37e13edbe3b48f1095a9d085ef9cd83b
+https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda#9c5491066224083c41b6d5635ed7107b
+https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py313h5ea7bf4_0.conda#c67e190eb3d8264c5b281c1e0f1b115a
+https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda#d154b40b109e503430979e8a8d099eaf
+https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7
+https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda#8a0d65027e25e367f9f1754f0604e8de
+https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda#de3551bf6508d45ca46b714639e52823
+https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda#1566e2ce8c3bc23a2feb866c4d9e91e3
+https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda#9e8dd0d90ed830107b2c36801035b7db
+https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda#95591ca5671d2213f5b2d5aa7818420d
+https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda#6a01c986e30292c715038d2788aa1385
+https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda#8ee01a693aecff5432069eaaf1183c45
+https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda#5afbf28c4ca3e05b5469dbbd78c8e704
+https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda#4a0ce24b1a946ff77ae9eaa7ef015a33
+https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda#09f1d8e4d2675d34ad2acb115211d10c
+https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda#d584799b920ecae9b75a2b70743a3de7
+https://conda.anaconda.org/conda-forge/win-64/numpy-2.4.6-py313ha8dc839_0.conda#1546190d6b2a2605ad960693018b874b
+https://conda.anaconda.org/conda-forge/win-64/contourpy-1.3.3-py313h1a38498_4.conda#726aa233b5e4613e546ca84cd63cbd45
+https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1
+https://conda.anaconda.org/conda-forge/win-64/dav1d-1.2.1-hcfcfb64_0.conda#ed2c27bda330e3f0ab41577cf8b9b585
+https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.conda#61dcf784d59ef0bd62c57d982b154ace
+https://conda.anaconda.org/conda-forge/win-64/vs2015_runtime-14.51.36231-h84cd919_39.conda#2ccc63d7b7d066a814ed9f99072832d7
+https://conda.anaconda.org/conda-forge/win-64/lame-3.100-hcfcfb64_1003.tar.bz2#d92e64077c44c9e32c72d4b5799d47e4
+https://conda.anaconda.org/conda-forge/win-64/graphite2-1.3.15-hac47afa_0.conda#ff9a9bfe791f56b0227597a7651a6af0
+https://conda.anaconda.org/conda-forge/win-64/libharfbuzz-14.2.1-h03b5201_1.conda#005469a341088900ca235892d3154c24
+https://conda.anaconda.org/conda-forge/win-64/libhwy-1.4.0-h2419aca_0.conda#430378e206cf5a148fc8da7603b2466e
+https://conda.anaconda.org/conda-forge/win-64/libjxl-0.12.0-h932607e_1.conda#adb20d060513fe9655d28a780bafafaa
+https://conda.anaconda.org/conda-forge/win-64/libopus-1.6.1-h6a83c73_0.conda#0ed21da5b6e3a0393e05762b3cce2878
+https://conda.anaconda.org/conda-forge/win-64/libjpeg-turbo-3.2.0-hfd05255_0.conda#cf219146d5bf2fee5907409ff9f5ac89
+https://conda.anaconda.org/conda-forge/win-64/lerc-4.2.0-hd936e49_0.conda#add59e2b60ac9d4299d17c938185c75a
+https://conda.anaconda.org/conda-forge/win-64/libdeflate-1.25-h51727cc_0.conda#e77030e67343e28b084fabd7db0ce43e
+https://conda.anaconda.org/conda-forge/win-64/libtiff-4.7.2-h8f73337_0.conda#e83f459471905a04ebe15e21d063c49d
+https://conda.anaconda.org/conda-forge/win-64/gdk-pixbuf-2.44.7-h1f5b9c4_0.conda#5daba86dbe8072c7e49f74013ef308cd
+https://conda.anaconda.org/conda-forge/win-64/zlib-1.3.2-hfd05255_2.conda#5187ecf958be3c39110fe691cbd6873e
+https://conda.anaconda.org/conda-forge/win-64/freetype-2.14.3-h57928b3_1.conda#e77293b32225b136a8be300f93d0e89f
+https://conda.anaconda.org/conda-forge/win-64/glib-tools-2.88.2-h74ecf4c_0.conda#63c615f0c525ee64f72e557e583b6257
+https://conda.anaconda.org/conda-forge/win-64/libintl-devel-0.22.5-h5728263_3.conda#7537784e9e35399234d4007f45cdb744
+https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934
+https://conda.anaconda.org/conda-forge/win-64/glib-2.88.2-h395db07_0.conda#7d203837b88a2255b32dca555d37ca50
+https://conda.anaconda.org/conda-forge/win-64/libharfbuzz-devel-14.2.1-h03b5201_1.conda#de077ebf9cbc0c1da6510fdf1bbc6baa
+https://conda.anaconda.org/conda-forge/win-64/harfbuzz-14.2.1-h57928b3_1.conda#e706de885f817f9832c56f1c9ad7ce71
+https://conda.anaconda.org/conda-forge/win-64/fribidi-1.0.16-hfd05255_0.conda#c27bd87e70f970010c1c6db104b88b18
+https://conda.anaconda.org/conda-forge/win-64/pango-1.58.0-h13911b6_0.conda#d706348be7393bbd156fc15ce1345587
+https://conda.anaconda.org/conda-forge/win-64/librsvg-2.62.3-h15cfe45_0.conda#3fffc63af7b943cde57aa72f5ffe6048
+https://conda.anaconda.org/conda-forge/win-64/libogg-1.3.5-h2466b09_1.conda#b67ed8c9ca072695ff482e50d888a523
+https://conda.anaconda.org/conda-forge/win-64/libvorbis-1.3.7-h5112557_2.conda#42a8a56c60882da5d451aa95b8455111
+https://conda.anaconda.org/conda-forge/win-64/libvulkan-loader-1.4.350.1-h477610d_0.conda#9cacbf81324479c89f59afcbf93043ee
+https://conda.anaconda.org/conda-forge/win-64/libwebp-base-1.6.0-h4d5522a_0.conda#f9bbae5e2537e3b06e0f7310ba76c893
+https://conda.anaconda.org/conda-forge/win-64/openh264-2.6.0-h1eab103_1.conda#91c186a483e5491170156399b2850804
+https://conda.anaconda.org/conda-forge/win-64/libusb-1.0.29-h1839187_0.conda#a656b2c367405cd24988cf67ff2675aa
+https://conda.anaconda.org/conda-forge/win-64/sdl3-3.4.12-h5112557_0.conda#5f80121d90de6623ae8e0eee34da16ff
+https://conda.anaconda.org/conda-forge/win-64/sdl2-2.32.56-h5112557_0.conda#4cffbfebb6614a1bff3fc666527c25c7
+https://conda.anaconda.org/conda-forge/win-64/spirv-tools-2026.2-h49e36cd_1.conda#5a929a4b1f6c82d05f0a1283d4a14e45
+https://conda.anaconda.org/conda-forge/win-64/glslang-16.3.0-h294ba9c_0.conda#7d6fed8a6ebeeebd6362790e22e56bb3
+https://conda.anaconda.org/conda-forge/win-64/shaderc-2026.2-h8fa7867_0.conda#dd6d0d119b1ca747af3ba964eaa3c565
+https://conda.anaconda.org/conda-forge/win-64/svt-av1-4.0.1-hac47afa_0.conda#d9714a97bc69f98fd5032f675ae1b0b5
+https://conda.anaconda.org/conda-forge/win-64/x264-1!164.3095-h8ffe710_2.tar.bz2#19e39905184459760ccb8cf5c75f148b
+https://conda.anaconda.org/conda-forge/win-64/x265-3.5-h2d74725_3.tar.bz2#ca7129a334198f08347fb19ac98a2de9
+https://conda.anaconda.org/conda-forge/win-64/ffmpeg-8.1.2-gpl_hdd6294f_902.conda#6efa244e707348f5b174e4cf4f945446
+https://conda.anaconda.org/conda-forge/noarch/filelock-3.32.0-pyhd8ed1ab_0.conda#ac3366aa3754b212f91588b1ae3e54dc
+https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609
+https://conda.anaconda.org/conda-forge/win-64/fonttools-4.63.0-py313hd650c13_0.conda#2b7be2be35fc3b035f1365a015af9706
+https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.6.0-pyhd8ed1ab_0.conda#7d7e6c826ba0743fc491ebee0e7b899c
+https://conda.anaconda.org/conda-forge/win-64/getopt-win32-0.1-h6a83c73_3.conda#49c36fcad2e9af6b91e91f2ce5be8ebd
+https://conda.anaconda.org/conda-forge/win-64/gts-0.7.6-h6b5321d_4.conda#a41f14768d5e377426ad60c613f2923b
+https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda#00528c2577868b9cfefec85b8fe26d66
+https://conda.anaconda.org/conda-forge/win-64/pthread-stubs-0.4-h0e40799_1002.conda#3c8f2573569bb816483e5cf57efbbe29
+https://conda.anaconda.org/conda-forge/win-64/xorg-libxau-1.0.12-hba3369d_1.conda#8436cab9a76015dfe7208d3c9f97c156
+https://conda.anaconda.org/conda-forge/win-64/xorg-libxdmcp-1.1.5-hba3369d_1.conda#a7c03e38aa9c0e84d41881b9236eacfb
+https://conda.anaconda.org/conda-forge/win-64/libxcb-1.17.0-h0e4246c_0.conda#a69bbf778a462da324489976c84cfc8c
+https://conda.anaconda.org/conda-forge/win-64/xorg-libx11-1.8.13-hfa52320_0.conda#5a823e21e090f8bc43dbfba00cd2f0e2
+https://conda.anaconda.org/conda-forge/win-64/xorg-libxext-1.3.7-hba3369d_0.conda#74bc8e26c2716e9b1542bef908887b82
+https://conda.anaconda.org/conda-forge/win-64/xorg-libice-1.1.2-h0e40799_0.conda#105cb93a47df9c548e88048dc9cbdbc9
+https://conda.anaconda.org/conda-forge/win-64/xorg-libsm-1.2.6-h0e40799_0.conda#570c9a6d9b4909e45d49e9a5daa528de
+https://conda.anaconda.org/conda-forge/win-64/xorg-libxt-1.3.1-h0e40799_0.conda#31baf0ce8ef19f5617be73aee0527618
+https://conda.anaconda.org/conda-forge/win-64/xorg-libxpm-3.5.19-hba3369d_0.conda#39d8a6b9a87047c817e5881fc0706684
+https://conda.anaconda.org/conda-forge/win-64/libgd-2.3.3-h4974f7c_12.conda#3a5b40267fcd31f1ba3a24014fe92044
+https://conda.anaconda.org/conda-forge/win-64/graphviz-14.1.2-h4c50273_0.conda#afabed4c46b197b89eb974aa038d12db
+https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py313h927ade5_0.conda#c19c39be5fc99714b9b6ea8a6178ccb6
+https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda#b8993c19b0c32a2f7b66cbb58ca27069
+https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda#b395909221b9bd1df066e5930e18855b
+https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac
+https://conda.anaconda.org/conda-forge/noarch/h2-4.4.0-pyhcf101f3_0.conda#aae3214aab65ae635fbb3cc455596a86
+https://conda.anaconda.org/conda-forge/noarch/setuptools-83.0.0-pyh332efcf_0.conda#6bf6acbab2499830180ec88c3aff2fa4
+https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda#615de2a4d97af50c350e5cf160149e77
+https://conda.anaconda.org/conda-forge/noarch/narwhals-2.24.0-pyhcf101f3_0.conda#42ef6cbb3e1d0e6689b9dd160f560eae
+https://conda.anaconda.org/conda-forge/win-64/scipy-1.18.0-py313he51e9a2_0.conda#ddf3fb8bab2ad4ffefad64803ffdbdfb
+https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda#9d64911b31d57ca443e9f1e36b04385f
+https://conda.anaconda.org/conda-forge/win-64/scikit-learn-1.9.0-np2py313h4ce4a18_0.conda#7cf535df7dc3f75881d06532677f5caa
+https://conda.anaconda.org/conda-forge/win-64/hdbscan-0.8.44-py313h0591002_0.conda#c11703de0d382eedcd2845b7e5eba84d
+https://conda.anaconda.org/conda-forge/win-64/hf-xet-1.5.2-py310hfb9af98_2.conda#b8d4ccb9fe3ea368ebe0bbb7a28ac4ca
+https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda#03fe290994c5e4ec17293cfb6bdce520
+https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda#4f14640d58e2cc0aa0819d9d8ba125bb
+https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda#d6989ead454181f4f9bc987d3dc4e285
+https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda#433699cba6602098ae8957a323da2664
+https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py313hd650c13_1.conda#c1bdb8dd255c79fb9c428ad25cc6ee54
+https://conda.anaconda.org/conda-forge/noarch/tqdm-4.70.0-pyha7b4d00_0.conda#7eac270516c8221cedc7f40a96d7fb8f
+https://conda.anaconda.org/conda-forge/noarch/huggingface_hub-1.25.1-pyhcf101f3_0.conda#8bad06cb19931cd97ea38bbdda41dd1e
+https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py313hd650c13_1.conda#5cc690ddf943700e0ef50a265df31f03
+https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d
+https://conda.anaconda.org/conda-forge/win-64/liblapacke-3.11.0-8_h3ae206f_mkl.conda#e4039fcb8a1690441bb7f63fb50b7b9a
+https://conda.anaconda.org/conda-forge/win-64/kaldi-5.5.1172-cpu_hf03c2bf_5.conda#dc72a115ec25db55c831282d4cf79eb4
+https://conda.anaconda.org/conda-forge/noarch/lazy-loader-0.5-pyhd8ed1ab_0.conda#75932da6f03a6bef32b70a51e991f6eb
+https://conda.anaconda.org/conda-forge/noarch/lazy_loader-0.5-pyhd8ed1ab_0.conda#4c8327180586e7b1cd8b6815fc8827f1
+https://conda.anaconda.org/conda-forge/win-64/kiwisolver-1.5.0-py313h1a38498_0.conda#7e40c4c1af80d907eb2973ab73418095
+https://conda.anaconda.org/conda-forge/win-64/libraqm-0.11.0-h50d6d30_0.conda#df92be5685f712989830bdb7ee39383a
+https://conda.anaconda.org/conda-forge/win-64/lcms2-2.19.1-hf2c6c5f_1.conda#1df4012c8a2478699d07bc26af66d41e
+https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h0e57b4f_0.conda#e723ab7cc2794c954e1b22fde51c16e4
+https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda#46a21c0a4e65f1a135251fc7c8663f83
+https://conda.anaconda.org/conda-forge/win-64/pillow-12.3.0-py313h38f99e1_0.conda#c9444f203e39d00c45fff0a57d684064
+https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5
+https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3
+https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8
+https://conda.anaconda.org/conda-forge/win-64/qhull-2020.2-hc790b64_5.conda#854fbdff64b572b5c0b470f334d34c11
+https://conda.anaconda.org/conda-forge/win-64/matplotlib-base-3.11.1-py313h4c28798_1.conda#a1fb4236a919efdb80f713b0a70dd463
+https://conda.anaconda.org/conda-forge/win-64/msgpack-python-1.2.1-py313h1a38498_1.conda#26faa18b0b9a5466f65d0f309424babf
+https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.48.0-py313h9a11d27_1.conda#9c182322ca1b599e0d3489e9ce5fd3ce
+https://conda.anaconda.org/conda-forge/win-64/numba-0.66.0-py313h7bbedcd_0.conda#82d57695afcd4754256b781d26a9eb89
+https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.0-pyhcf101f3_0.conda#1fadaa6dd1d03d062075f84157ac2cc7
+https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda#46e441ba871f524e2b067929da3051c2
+https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda#e2fd202833c4a981ce8a65974fe4abd1
+https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e
+https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda#4a85203c1d80c1059086ae860836ffb9
+https://conda.anaconda.org/conda-forge/noarch/pooch-1.9.0-pyhd8ed1ab_0.conda#dd4b6337bf8886855db6905b336db3c8
+https://conda.anaconda.org/conda-forge/win-64/libflac-1.5.0-h08124e9_1.conda#949f7783753b48ede3ceaa1ee9876f0c
+https://conda.anaconda.org/conda-forge/win-64/mpg123-1.32.9-h01009b0_0.conda#1ed1580d4211223b285787eff05560f9
+https://conda.anaconda.org/conda-forge/win-64/libsndfile-1.2.2-hc3b4fa0_2.conda#31122eb784ae36b096fadeadd30cfabf
+https://conda.anaconda.org/conda-forge/noarch/pysoundfile-0.14.0-pyhcf101f3_0.conda#7917a903d86ef25e4da7b26b971bdd9a
+https://conda.anaconda.org/conda-forge/win-64/soxr-0.1.3-hcfcfb64_3.conda#8112bd0d3eb530551b2adebda0ea4c0c
+https://conda.anaconda.org/conda-forge/win-64/soxr-python-1.1.0-py313hfe59770_0.conda#64522efec7af9c12627726ce7c18da61
+https://conda.anaconda.org/conda-forge/noarch/standard-chunk-3.13.0-pyhd8ed1ab_0.conda#baaf1cc992662d1defb4aa0a60743d74
+https://conda.anaconda.org/conda-forge/win-64/standard-aifc-3.13.0-py313hfa70ccb_3.conda#78625bb05f1272c06cce6c150ff8c94f
+https://conda.anaconda.org/conda-forge/win-64/standard-sunau-3.13.0-py313hfa70ccb_3.conda#2b601c6f518bcb95e018bb33d8c956b0
+https://conda.anaconda.org/conda-forge/noarch/librosa-0.11.0-pyhd8ed1ab_0.conda#dc9d871c7bc97f21b25a350d0508ecd7
+https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda#c680b5747e8c4c8f23dca0bb7042a8fc
+https://conda.anaconda.org/conda-forge/noarch/praatio-6.2.2-pyhd8ed1ab_0.conda#6a684bdeb2ff7f11b697d30a4352c278
+https://conda.anaconda.org/conda-forge/win-64/pynini-2.1.7-py313hf069bd2_2.conda#193502b1e5b8f8e099fb2864d9597d1f
+https://conda.anaconda.org/conda-forge/win-64/kalpy-0.10.4-py313hf069bd2_0.conda#1af4daaca28ee001747a60b45faa3986
+https://conda.anaconda.org/conda-forge/noarch/kneed-0.8.6-pyhd8ed1ab_0.conda#172852fe4069b4da155e191bd1b1d7f9
+https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda#00335c2c4a98656554771aaf6f1a7400
+https://conda.anaconda.org/conda-forge/win-64/liblzma-devel-5.8.3-hfd05255_0.conda#7845201435d0c7c0a02269c2742da1cc
+https://conda.anaconda.org/conda-forge/win-64/libmad-0.15.1b-hcfcfb64_1001.conda#b6689654bd79a44e67f4d6ecca3d8b5b
+https://conda.anaconda.org/conda-forge/win-64/libpq-16.14-h43e12c5_0.conda#6003d76caee55afa8390d30c9bdac1e4
+https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda#592132998493b3ff25fd7479396e8351
+https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda#6d03368f2b2b0a5fb6839df53b2eb5e0
+https://conda.anaconda.org/conda-forge/win-64/ngram-1.3.17-hc790b64_0.conda#55611abc58c2d2f9820f1a4a8acc5441
+https://conda.anaconda.org/conda-forge/win-64/pgvector-0.8.3-h2466b09_0.conda#b7341230f28bb6aad90804fbdd5b91c2
+https://conda.anaconda.org/conda-forge/noarch/pgvector-python-0.5.0-pyhcf101f3_0.conda#087ccfaa08b6da879528f7b70f69bb85
+https://conda.anaconda.org/conda-forge/win-64/postgresql-16.14-he837cf3_0.conda#bd37e723e691225d8b7b472e5bab50dc
+https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.9-py313hb141f5c_1.conda#8c28a96f7fc91ed9bbf530a5ab53bbe6
+https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0
+https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda#0242025a3c804966bf71aa04eee82f66
+https://conda.anaconda.org/conda-forge/noarch/rich-click-1.9.8-pyha7b4d00_0.conda#3374cf8404b1798d1cbb82787c996217
+https://conda.anaconda.org/conda-forge/win-64/sox-14.4.2-hb83ef69_1021.conda#e7116d9fb3061aae5876f9ff1383d7e6
+https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py313h5fd188c_0.conda#4010f1dc0c5d2322a58acc0fa0c86790
+https://conda.anaconda.org/conda-forge/win-64/sqlite-3.53.4-hdb435a2_0.conda#dafb42fc12203b56f5574658613d256e
+https://conda.anaconda.org/conda-forge/noarch/montreal-forced-aligner-3.4.1-pyhd8ed1ab_0.conda#847b739ba73b730cdade23ca5cc62c76
+https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda#733cc07ed34162ac50b936464b163366
diff --git a/docs/BUILD.md b/docs/BUILD.md
new file mode 100644
index 0000000..d15d296
--- /dev/null
+++ b/docs/BUILD.md
@@ -0,0 +1,175 @@
+# Building VoxKit and the bundled MFA environment
+
+This covers two things: how VoxKit's own executable is built, and how it
+provisions the Montreal Forced Aligner (MFA) so end users never have to
+install conda or run a conda command themselves.
+
+## Table of Contents
+
+- [How MFA setup works for end users](#how-mfa-setup-works-for-end-users)
+- [Regenerating the pinned MFA environment lockfile](#regenerating-the-pinned-mfa-environment-lockfile)
+- [The vendored micromamba binary](#the-vendored-micromamba-binary)
+- [Building the VoxKit executable](#building-the-voxkit-executable)
+- [Troubleshooting / the conda-path fallback](#troubleshooting--the-conda-path-fallback)
+
+---
+
+## How MFA setup works for end users
+
+VoxKit ships a small (~10MB) static [micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html)
+binary (`vendor/micromamba/`) plus a pinned, platform-specific *explicit*
+lockfile (`config/mfa-env/aligner-.lock`) describing an environment
+equivalent to `conda create -n aligner -c conda-forge montreal-forced-aligner`.
+
+On first launch, `config/startup_config.py`'s `startup_routine()` calls
+`voxkit.services.mfa_provision.provision_aligner_env()`, which runs:
+
+```
+micromamba create -p ~/.voxkit/mfa-env --file config/mfa-env/aligner-win-64.lock -y
+```
+
+This installs the exact pinned package set directly from URLs, with no
+dependency solve step -- fast, reproducible, and safe to just re-run if it's
+interrupted (micromamba caches downloaded packages). It requires one
+~1-2GB download, same order of magnitude as `conda create` would need
+anyway, just automated and without a terminal.
+
+`voxkit.services.mfa._mfa_invocation()` prefers this bundled environment
+whenever it's ready. If a user already has their own working conda +
+`aligner` environment (e.g. from before this feature existed, or via the
+"Conda Path" setting), that continues to work exactly as before with zero
+behavior change -- the bundled environment is purely additive.
+
+If first-run provisioning ever fails (e.g. a network hiccup mid-download),
+it does **not** block the rest of app setup or W2TG usage. It also isn't
+retried automatically on next launch (unlike the app's other first-run
+downloads) -- retry it manually via the **"Repair/Reinstall MFA
+Environment"** button on the Generate Alignments page.
+
+### Real-world gotchas discovered building this (read before touching `_mfa_invocation`)
+
+These were all found via actual hands-on testing while building this
+feature, not theoretical:
+
+- **The `mfa`/`mfa.exe` entry-point stub can fail to launch** ("failed to
+ create process") on at least one real Windows machine, even inside a
+ correctly-activated `micromamba run -p ` environment. Invoking
+ `python /Scripts/mfa-script.py` directly (still via `micromamba run
+ -p `, so the environment is still activated) works reliably where the
+ stub does not. `_mfa_invocation()` uses this form for the bundled
+ environment specifically for this reason -- don't change it back to
+ calling `mfa`/`mfa.exe` without re-verifying on a real machine.
+- **Environment activation matters for native libraries.** Calling
+ `/python.exe` *without* going through `micromamba run -p ` first
+ breaks DLL loading for `libsndfile`/Kaldi (the env's `Library/bin` isn't
+ on the search path). Always invoke through `micromamba run -p `.
+- **MFA's global config/database directory must be isolated.** MFA defaults
+ to `~/Documents/MFA` for its global config and Postgres data. A
+ pre-existing config written by a different MFA version can fail to load
+ under a newer version's stricter YAML loader (confirmed: a real
+ `global_config.yaml` from an older MFA install couldn't be read by a
+ fresh 3.4.1 environment). Setting the `MFA_ROOT_DIR` environment variable
+ to a VoxKit-owned directory (`~/.voxkit/mfa-root`, via
+ `mfa_provision.mfa_root_dir()`) keeps the bundled environment's MFA state
+ fully separate from any pre-existing user setup.
+- **PostgreSQL's Unix-domain socket path has a hard 107-byte limit.**
+ `mfa server init`/`start` (the Windows SQLite-race workaround) fails
+ outright with a deeply nested `MFA_ROOT_DIR` -- confirmed via
+ reproduction. `~/.voxkit/mfa-root` is short enough to be safe for the
+ vast majority of users, but be aware of this limit if you ever change
+ where that directory lives.
+
+## Regenerating the pinned MFA environment lockfile
+
+Only needed when bumping the pinned MFA version, or adding a new platform.
+This is a developer maintenance task, not part of VoxKit's own release
+process -- it does not require conda or conda-lock, just the vendored
+micromamba binary.
+
+**Always pin kaldi to a CPU build (`kaldi=*=cpu*`).** conda-forge ships both
+CPU and CUDA variants of kaldi at the same version, and the solver picks
+between them based on the `__cuda` virtual package -- i.e. on whether the
+machine generating the lockfile happens to have an NVIDIA driver. Without
+the pin, a lockfile solved on a GPU machine bakes in `kaldi-*-cuda*`, whose
+`kaldi-cudamatrix.dll` links against `nvcuda.dll`. That DLL ships with the
+NVIDIA display driver, never in a conda package, so the environment fails
+to import for every user without an NVIDIA GPU:
+
+```
+ImportError: DLL load failed while importing _kalpy: The specified module could not be found.
+```
+
+The CUDA variant buys VoxKit nothing regardless: kaldi's CUDA components
+accelerate nnet3 online decoding, while `mfa align`/`mfa adapt` are
+GMM-based and CPU-only. Pinning CPU also drops ~12 CUDA packages
+(`libcublas`, `libcusolver`, `libmagma`, ...) from the installer.
+
+```powershell
+# From the repo root, using the already-vendored micromamba binary.
+# Note the kaldi CPU pin -- see above, do not drop it:
+.\vendor\micromamba\micromamba.exe create -p .\_tmp-aligner -c conda-forge montreal-forced-aligner "kaldi=*=cpu*" -y
+
+# Export the explicit, pinned lockfile:
+.\vendor\micromamba\micromamba.exe env export -p .\_tmp-aligner --explicit --md5 `
+ | Out-File -Encoding ascii config\mfa-env\aligner-win-64.lock
+
+# Confirm no CUDA packages leaked into the lockfile (must print nothing):
+Select-String -Path config\mfa-env\aligner-win-64.lock -Pattern 'cuda|cublas|cusolver|cusparse|curand|cufft|nvrtc|magma'
+
+# Validate the round-trip before committing -- create a fresh env from just
+# the lockfile (no solve, pure download) and confirm `mfa` actually works:
+.\vendor\micromamba\micromamba.exe create -p .\_tmp-aligner-verify --file config\mfa-env\aligner-win-64.lock -y
+$env:MFA_ROOT_DIR = "C:\_tmp-mfa-root"
+# Import _kalpy explicitly: a CUDA-variant kaldi still passes `mfa version`
+# on a GPU machine but fails at import time on every machine without one.
+.\vendor\micromamba\micromamba.exe run -p .\_tmp-aligner-verify python -c "import _kalpy"
+.\vendor\micromamba\micromamba.exe run -p .\_tmp-aligner-verify python .\_tmp-aligner-verify\Scripts\mfa-script.py version
+
+# Clean up the local scratch environments (do not commit them):
+Remove-Item -Recurse -Force .\_tmp-aligner, .\_tmp-aligner-verify
+```
+
+Commit only `config/mfa-env/aligner-win-64.lock` -- never the scratch
+environment directories themselves (multi-GB).
+
+mac/linux lockfiles (`aligner-osx-64.lock`, `aligner-linux-64.lock`, etc.)
+are a natural follow-up once this pattern is proven further on Windows, not
+required today -- `mfa_provision.lockfile_path()` returns `None` on
+platforms without one, and every call site treats that as "fall back to a
+user-managed conda + aligner environment," not an error.
+
+## The vendored micromamba binary
+
+`vendor/micromamba/micromamba.exe` is the official static release from
+[mamba-org/micromamba-releases](https://github.com/mamba-org/micromamba-releases).
+To update it:
+
+```powershell
+curl -L -o vendor\micromamba\micromamba.exe `
+ https://github.com/mamba-org/micromamba-releases/releases/latest/download/micromamba-win-64
+```
+
+Re-run the lockfile validation steps above afterward to confirm the new
+binary still provisions and invokes correctly.
+
+## Building the VoxKit executable
+
+Unchanged from before this feature: `invoke windows-build` (or
+`macos-build`/`linux-build`) wraps `scripts/build.py`, which drives
+PyInstaller directly (no committed `.spec` file). `vendor/` and `config/`
+are both bundled via `--add-data`, resolved at runtime through
+`sys._MEIPASS` when frozen (see `mfa_provision._bundle_root()`).
+
+The Windows Inno Setup installer (`installer/windows/VoxKit.iss`) needs no
+changes -- `vendor/`+`config/` are already inside the PyInstaller bundle
+(`dist/VoxKit.exe`), not separate installer assets.
+
+## Troubleshooting / the conda-path fallback
+
+If a user's platform has no bundled lockfile yet, or they prefer their own
+MFA install, the pre-existing "Conda Path" field in MFA engine settings
+(Generate Alignments -> ⚙️) still works exactly as it always has: point it
+at a `conda`/`conda.exe` with an `aligner` environment
+(`conda create -n aligner -c conda-forge montreal-forced-aligner`), and
+`_mfa_invocation()` falls back to that path whenever the bundled
+environment isn't ready.
diff --git a/scripts/build.py b/scripts/build.py
index 05e31d3..70811ed 100644
--- a/scripts/build.py
+++ b/scripts/build.py
@@ -177,6 +177,13 @@ def build(args):
print(f"[INFO] Adding config folder to build assets")
opts.append(f'--add-data={config_dir}{sep}config')
+ # Add vendor folder if it exists (vendored micromamba binary, used to
+ # provision the bundled MFA environment -- see docs/BUILD.md)
+ vendor_dir = project_root / "vendor"
+ if vendor_dir.exists() and vendor_dir.is_dir():
+ print("[INFO] Adding vendor folder to build assets")
+ opts.append(f'--add-data={vendor_dir}{sep}vendor')
+
for ad in args.add_data:
if sep in ad:
opts.append(f'--add-data={ad}')
diff --git a/src/voxkit/config/startup_config.py b/src/voxkit/config/startup_config.py
index 2e71945..5dfc127 100644
--- a/src/voxkit/config/startup_config.py
+++ b/src/voxkit/config/startup_config.py
@@ -1,6 +1,7 @@
import time
from typing import Callable, Literal
+from voxkit.services import mfa_provision
from voxkit.services.mfa import download_acoustic_model
from voxkit.storage import models
from voxkit.storage.constants import MODELS_ROOT
@@ -103,6 +104,20 @@ def startup_routine():
except Exception as e:
print(f"[STARTUP] Failed to download NLTK resources. Error: {e}")
+ # Provision VoxKit's own managed MFA ("aligner") environment, so most
+ # users never need conda installed or run a conda command themselves.
+ # Non-fatal like the other steps above -- a failure here shouldn't block
+ # W2TG (which doesn't need MFA) or the rest of first-run setup. Retriable
+ # afterward via the "Repair/Reinstall MFA Environment" action in the MFA
+ # engine's settings, so it's not tied to the whole first-launch flag.
+ if mfa_provision.lockfile_path() is not None and not mfa_provision.is_aligner_env_ready():
+ print("[STARTUP] Setting up the MFA alignment environment (one-time, ~1-2GB)...")
+ try:
+ mfa_provision.provision_aligner_env()
+ print("[STARTUP] MFA alignment environment ready.")
+ except Exception as e:
+ print(f"[STARTUP] Failed to set up the MFA alignment environment. Error: {e}")
+
print("[STARTUP] Initialization complete!")
diff --git a/src/voxkit/gui/pages/pipeline/prediction_stacker.py b/src/voxkit/gui/pages/pipeline/prediction_stacker.py
index 89f0fca..c65938d 100644
--- a/src/voxkit/gui/pages/pipeline/prediction_stacker.py
+++ b/src/voxkit/gui/pages/pipeline/prediction_stacker.py
@@ -18,6 +18,7 @@
from voxkit.gui.frameworks.settings_modal import GenericDialog
from voxkit.gui.styles import Buttons, Containers, Labels
from voxkit.gui.workers.worker_thread import WorkerThread
+from voxkit.services import mfa_provision
from voxkit.storage import datasets
from .base_stacker import BaseStacker
@@ -139,6 +140,14 @@ def build_ui(self):
self.predict_btn.clicked.connect(self.on_predict_alignments)
self.content_layout.addWidget(self.predict_btn)
+ # MFA sets itself up automatically on first launch; this is a manual
+ # escape hatch for retrying if that ever fails, without reinstalling
+ # the app. A no-op for any other engine.
+ self.repair_mfa_btn = QPushButton("Repair/Reinstall MFA Environment")
+ self.repair_mfa_btn.setStyleSheet(Buttons.SECONDARY)
+ self.repair_mfa_btn.clicked.connect(self.on_repair_mfa_environment)
+ self.content_layout.addWidget(self.repair_mfa_btn)
+
def on_predict_alignments(self):
"""Handle Predict Alignments button click."""
selected_dataset_id = self.predict_dataset_dropdown.current_id()
@@ -187,3 +196,41 @@ def on_predict_finished(self, success, message):
else:
self.set_status("✗ Error occurred", "error")
QMessageBox.critical(self, "Error", f"An error occurred:\n{message}")
+
+ def on_repair_mfa_environment(self):
+ """Handle the "Repair/Reinstall MFA Environment" button click."""
+ if self.model_panel.get_selected_engine() != "MFAENGINE":
+ QMessageBox.information(
+ self,
+ "Not Applicable",
+ "This only applies to the MFA engine -- select MFA above first.",
+ )
+ return
+
+ if mfa_provision.lockfile_path() is None:
+ QMessageBox.warning(
+ self,
+ "Not Available",
+ "No bundled MFA environment is available for this platform yet. "
+ "Configure 'Conda Path' in MFA settings to use your own conda + "
+ "aligner environment instead.",
+ )
+ return
+
+ self.repair_mfa_btn.setEnabled(False)
+ self.set_status("Setting up MFA environment (one-time, ~1-2GB)...", "working")
+
+ self.mfa_repair_worker = WorkerThread(mfa_provision.provision_aligner_env)
+ self.mfa_repair_worker.finished.connect(self.on_repair_mfa_finished)
+ self.mfa_repair_worker.start()
+
+ def on_repair_mfa_finished(self, success, message):
+ """Handle completion of the MFA environment repair operation."""
+ self.repair_mfa_btn.setEnabled(True)
+
+ if success:
+ self.set_status("✓ MFA environment ready", "success")
+ QMessageBox.information(self, "Success", "MFA environment is set up and ready.")
+ else:
+ self.set_status("✗ MFA environment setup failed", "error")
+ QMessageBox.critical(self, "Error", f"Failed to set up MFA environment:\n{message}")
diff --git a/src/voxkit/services/mfa.py b/src/voxkit/services/mfa.py
index b6c331f..01a1f87 100755
--- a/src/voxkit/services/mfa.py
+++ b/src/voxkit/services/mfa.py
@@ -4,6 +4,8 @@
import sys
from pathlib import Path
+from voxkit.services import mfa_provision
+
def _no_window() -> dict:
"""Return creationflags to suppress console windows on Windows."""
@@ -12,6 +14,42 @@ def _no_window() -> dict:
return {}
+def _mfa_invocation(conda_path: str | None = None) -> tuple[list[str], dict]:
+ """Return (command_prefix, extra_env) for invoking `mfa ...`.
+
+ Prefers VoxKit's own bundled/provisioned aligner environment
+ (`mfa_provision`) when it's ready, so most users never need conda
+ installed at all. Falls back to exactly the previous behavior --
+ `_find_conda(conda_path)` + `conda run -n aligner mfa` -- for anyone
+ with their own pre-existing conda + `aligner` environment, with zero
+ change to that path.
+
+ The bundled environment is invoked as `micromamba run -p python
+ /Scripts/mfa-script.py`, not the `mfa`/`mfa.exe` entry-point stub
+ directly -- confirmed via real-world testing that the stub can fail to
+ launch ("failed to create process") on a machine where invoking Python
+ with the underlying script directly, inside the same activated
+ environment, works correctly. `micromamba run -p ` still performs
+ full environment activation (PATH/DLL search paths Kaldi and the
+ bundled Postgres server need), just naming the interpreter explicitly
+ rather than relying on the stub.
+ """
+ # is_aligner_env_ready() only ever returns True on win32 in v1 (the only
+ # platform with a bundled lockfile today), so the Windows-specific
+ # env-layout paths below (Scripts/, python.exe) are always correct here.
+ if mfa_provision.is_aligner_env_ready():
+ micromamba = str(mfa_provision.vendored_micromamba_path())
+ env_path = mfa_provision.bundled_env_path()
+ python = str(env_path / "python.exe")
+ script = str(env_path / "Scripts" / "mfa-script.py")
+ prefix = [micromamba, "run", "-p", str(env_path), python, script]
+ extra_env = {"MFA_ROOT_DIR": str(mfa_provision.mfa_root_dir())}
+ return prefix, extra_env
+
+ conda = _find_conda(conda_path)
+ return [conda, "run", "-n", "aligner", "mfa"], {}
+
+
def _find_conda(conda_path: str | None = None) -> str:
"""Return the conda executable path.
@@ -87,37 +125,23 @@ def ensure_dictionary_downloaded(
Raises:
AssertionError: If dictionary download fails and dictionary is not available.
"""
- conda = _find_conda(conda_path)
- download_cmd = [
- conda,
- "run",
- "-n",
- "aligner",
- "mfa",
- "model",
- "download",
- "dictionary",
- dictionary_name,
- ]
+ prefix, extra_env = _mfa_invocation(conda_path)
+ run_env = {**os.environ, **extra_env}
+ download_cmd = [*prefix, "model", "download", "dictionary", dictionary_name]
print(f"[mfa] Ensuring dictionary '{dictionary_name}' is downloaded...")
- result = subprocess.run(download_cmd, capture_output=True, text=True, **_no_window())
+ result = subprocess.run(
+ download_cmd, capture_output=True, text=True, env=run_env, **_no_window()
+ )
# Check if dictionary is available (either just downloaded or already present)
# MFA returns success if already downloaded, or downloads successfully
if result.returncode != 0:
# Try to list dictionaries to check if it's already available
- list_cmd = [
- conda,
- "run",
- "-n",
- "aligner",
- "mfa",
- "model",
- "list",
- "dictionary",
- ]
- list_result = subprocess.run(list_cmd, capture_output=True, text=True, **_no_window())
+ list_cmd = [*prefix, "model", "list", "dictionary"]
+ list_result = subprocess.run(
+ list_cmd, capture_output=True, text=True, env=run_env, **_no_window()
+ )
assert dictionary_name in list_result.stdout, (
f"Dictionary '{dictionary_name}' is not available. "
f"Download failed with: {result.stderr}"
@@ -141,7 +165,8 @@ def _ensure_mfa_server_running(conda_path: str | None = None) -> None:
if sys.platform != "win32":
return
- conda = _find_conda(conda_path)
+ prefix, extra_env = _mfa_invocation(conda_path)
+ run_env = {**os.environ, **extra_env}
# Both calls are idempotent: `init` errors if the server dir already exists,
# `start` errors if it's already running. Either error state is the goal,
# so we ignore returncodes and only guard against true failures (timeout,
@@ -150,10 +175,11 @@ def _ensure_mfa_server_running(conda_path: str | None = None) -> None:
for sub in (("server", "init"), ("server", "start")):
try:
subprocess.run(
- [conda, "run", "-n", "aligner", "mfa", *sub],
+ [*prefix, *sub],
capture_output=True,
text=True,
timeout=60,
+ env=run_env,
**_no_window(),
)
except (subprocess.TimeoutExpired, FileNotFoundError):
@@ -187,20 +213,8 @@ def run_mfa_align(
ensure_dictionary_downloaded(dictionary_name, conda_path=conda_path)
_ensure_mfa_server_running(conda_path=conda_path)
- conda = _find_conda(conda_path)
- cmd = [
- conda,
- "run",
- "-n",
- "aligner",
- "mfa",
- "align",
- corpus_dir,
- dictionary_name,
- model_path,
- output_dir,
- "--clean", # Add clean flag to avoid cache issues
- ]
+ prefix, extra_env = _mfa_invocation(conda_path)
+ cmd = [*prefix, "align", corpus_dir, dictionary_name, model_path, output_dir, "--clean"]
if eval_dir:
cmd.append("--reference_alignments")
@@ -208,7 +222,14 @@ def run_mfa_align(
try:
print(f"[mfa.run_mfa_align] Running MFA align with command: {' '.join(cmd)}")
- subprocess.run(cmd, check=True, capture_output=True, text=True, **_no_window())
+ subprocess.run(
+ cmd,
+ check=True,
+ capture_output=True,
+ text=True,
+ env={**os.environ, **extra_env},
+ **_no_window(),
+ )
print("[mfa.run_mfa_align] MFA alignment completed successfully.")
except subprocess.CalledProcessError as e:
stderr_msg = e.stderr.strip() if e.stderr else "(no output captured)"
@@ -243,13 +264,9 @@ def run_mfa_adapt(
ensure_dictionary_downloaded(dictionary_name, conda_path=conda_path)
_ensure_mfa_server_running(conda_path=conda_path)
- conda = _find_conda(conda_path)
+ prefix, extra_env = _mfa_invocation(conda_path)
cmd = [
- conda,
- "run",
- "-n",
- "aligner",
- "mfa",
+ *prefix,
"adapt",
corpus_dir,
dictionary_name,
@@ -262,7 +279,14 @@ def run_mfa_adapt(
try:
print(f"[mfa.run_mfa_adapt] Running MFA adapt with command: {' '.join(cmd)}")
- subprocess.run(cmd, check=True, capture_output=True, text=True, **_no_window())
+ subprocess.run(
+ cmd,
+ check=True,
+ capture_output=True,
+ text=True,
+ env={**os.environ, **extra_env},
+ **_no_window(),
+ )
print("[mfa.run_mfa_adapt] MFA adaptation completed successfully.")
except subprocess.CalledProcessError as e:
print(f"[mfa.run_mfa_adapt] MFA adaptation failed with error: {e}")
diff --git a/src/voxkit/services/mfa_provision.py b/src/voxkit/services/mfa_provision.py
new file mode 100644
index 0000000..35214d1
--- /dev/null
+++ b/src/voxkit/services/mfa_provision.py
@@ -0,0 +1,122 @@
+"""MFA environment provisioning via a vendored, activation-preserving micromamba.
+
+Bundles a small static `micromamba` binary plus a pinned, platform-specific
+explicit lockfile (see `config/mfa-env/`) so VoxKit can materialize its own
+"aligner" environment on first use, without requiring the user to install
+conda or run any terminal commands themselves. `services/mfa.py` prefers
+this bundled environment when it's ready, falling back to the existing
+user-managed conda + `aligner` env mechanism otherwise.
+
+See `docs/BUILD.md` for how to regenerate the lockfile when MFA's pinned
+version changes, and where the vendored micromamba binary comes from.
+
+API
+---
+- **bundled_env_path**: Where VoxKit's own managed aligner environment lives
+- **mfa_root_dir**: Isolated MFA_ROOT_DIR for the bundled environment
+- **is_aligner_env_ready**: Whether the bundled environment has been provisioned
+- **provision_aligner_env**: Create the bundled environment from the pinned lockfile
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+from voxkit.storage.utils import get_storage_root
+
+_PLATFORM_TAGS = {"win32": "win-64"}
+
+
+def _no_window() -> dict:
+ """Return creationflags to suppress console windows on Windows."""
+ if sys.platform == "win32":
+ return {"creationflags": subprocess.CREATE_NO_WINDOW}
+ return {}
+
+
+def _bundle_root() -> Path:
+ """Root directory containing the vendored micromamba binary and lockfiles.
+
+ Resolves relative to the frozen PyInstaller bundle (`sys._MEIPASS`) when
+ running as a built app, or the repo root in dev.
+ """
+ if getattr(sys, "frozen", False) and getattr(sys, "_MEIPASS", None):
+ return Path(sys._MEIPASS) # type: ignore[attr-defined]
+ # src/voxkit/services/mfa_provision.py -> repo root is 4 parents up.
+ return Path(__file__).resolve().parents[3]
+
+
+def vendored_micromamba_path() -> Path:
+ """Path to the vendored micromamba binary for the current platform."""
+ name = "micromamba.exe" if sys.platform == "win32" else "micromamba"
+ return _bundle_root() / "vendor" / "micromamba" / name
+
+
+def lockfile_path() -> Path | None:
+ """Path to the pinned aligner-environment lockfile for the current platform.
+
+ Returns None on platforms without a bundled lockfile yet (v1 ships
+ win-64 only) -- callers should treat that as "bundled provisioning isn't
+ available here" rather than an error, falling back to the existing
+ user-managed conda mechanism.
+ """
+ platform_tag = _PLATFORM_TAGS.get(sys.platform)
+ if platform_tag is None:
+ return None
+ path = _bundle_root() / "config" / "mfa-env" / f"aligner-{platform_tag}.lock"
+ return path if path.exists() else None
+
+
+def bundled_env_path() -> Path:
+ """Fixed location where VoxKit provisions its own managed aligner environment."""
+ return get_storage_root() / "mfa-env"
+
+
+def mfa_root_dir() -> Path:
+ """Fixed, short MFA_ROOT_DIR to use with the bundled environment.
+
+ MFA's global config/database/working-data directory defaults to
+ ``~/Documents/MFA``. Pointing the bundled environment at a VoxKit-owned
+ directory instead keeps it fully isolated from any pre-existing
+ conda + MFA setup a user may already have -- avoids a
+ ``global_config.yaml`` version mismatch between the two (confirmed via
+ real-world testing: a fresh MFA 3.4.1 environment couldn't parse a
+ config written by an older MFA version), and keeps the path short
+ enough to stay under PostgreSQL's 107-byte Unix-domain-socket path
+ limit (also confirmed via real-world testing -- a deeply nested root
+ directory made ``mfa server init`` fail outright).
+ """
+ return get_storage_root() / "mfa-root"
+
+
+def is_aligner_env_ready() -> bool:
+ """Whether the bundled aligner environment has already been provisioned."""
+ return (bundled_env_path() / "Scripts" / "mfa-script.py").exists()
+
+
+def provision_aligner_env() -> None:
+ """Create the bundled aligner environment from the pinned lockfile.
+
+ Safe to re-run after a failure or interruption: micromamba caches
+ downloaded packages, so a re-run resumes from cache rather than
+ restarting the full download.
+
+ Raises:
+ FileNotFoundError: If the vendored micromamba binary or lockfile is missing.
+ subprocess.CalledProcessError: If provisioning fails.
+ """
+ micromamba = vendored_micromamba_path()
+ if not micromamba.exists():
+ raise FileNotFoundError(f"Vendored micromamba binary not found at {micromamba}")
+
+ lockfile = lockfile_path()
+ if lockfile is None:
+ raise FileNotFoundError(
+ f"No bundled MFA environment lockfile for platform {sys.platform!r}"
+ )
+
+ env_path = bundled_env_path()
+ env_path.parent.mkdir(parents=True, exist_ok=True)
+
+ cmd = [str(micromamba), "create", "-p", str(env_path), "--file", str(lockfile), "-y"]
+ subprocess.run(cmd, check=True, capture_output=True, text=True, **_no_window())
diff --git a/tests/services/test_mfa.py b/tests/services/test_mfa.py
index dea7cf5..e32c617 100644
--- a/tests/services/test_mfa.py
+++ b/tests/services/test_mfa.py
@@ -54,3 +54,55 @@ def test_find_conda_falls_back_to_path(monkeypatch):
monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
assert mfa._find_conda() == "conda"
+
+
+class TestMfaInvocation:
+ """_mfa_invocation() must prefer the bundled environment when it's ready,
+ and otherwise fall back to exactly the pre-bundling conda behavior."""
+
+ def test_falls_back_to_conda_when_bundle_not_ready(self, monkeypatch):
+ monkeypatch.setattr(mfa.mfa_provision, "is_aligner_env_ready", lambda: False)
+ monkeypatch.setattr(mfa.shutil, "which", lambda _: "conda")
+ monkeypatch.delenv("VOXKIT_CONDA_PATH", raising=False)
+
+ prefix, extra_env = mfa._mfa_invocation()
+
+ assert prefix == ["conda", "run", "-n", "aligner", "mfa"]
+ assert extra_env == {}
+
+ def test_prefers_bundled_env_when_ready(self, monkeypatch, tmp_path):
+ env_path = tmp_path / "mfa-env"
+ micromamba = tmp_path / "micromamba.exe"
+ mfa_root = tmp_path / "mfa-root"
+
+ monkeypatch.setattr(mfa.mfa_provision, "is_aligner_env_ready", lambda: True)
+ monkeypatch.setattr(mfa.mfa_provision, "vendored_micromamba_path", lambda: micromamba)
+ monkeypatch.setattr(mfa.mfa_provision, "bundled_env_path", lambda: env_path)
+ monkeypatch.setattr(mfa.mfa_provision, "mfa_root_dir", lambda: mfa_root)
+
+ prefix, extra_env = mfa._mfa_invocation()
+
+ assert prefix == [
+ str(micromamba),
+ "run",
+ "-p",
+ str(env_path),
+ str(env_path / "python.exe"),
+ str(env_path / "Scripts" / "mfa-script.py"),
+ ]
+ assert extra_env == {"MFA_ROOT_DIR": str(mfa_root)}
+
+ def test_bundled_env_takes_precedence_over_explicit_conda_path(self, monkeypatch, tmp_path):
+ """A user-configured conda_path is irrelevant once the bundle is ready --
+ the whole point is that most users never need to touch that setting."""
+ env_path = tmp_path / "mfa-env"
+ monkeypatch.setattr(mfa.mfa_provision, "is_aligner_env_ready", lambda: True)
+ monkeypatch.setattr(
+ mfa.mfa_provision, "vendored_micromamba_path", lambda: tmp_path / "mm.exe"
+ )
+ monkeypatch.setattr(mfa.mfa_provision, "bundled_env_path", lambda: env_path)
+ monkeypatch.setattr(mfa.mfa_provision, "mfa_root_dir", lambda: tmp_path / "mfa-root")
+
+ prefix, _ = mfa._mfa_invocation(conda_path="/some/explicit/conda")
+
+ assert "conda" not in prefix[0]
diff --git a/tests/services/test_mfa_provision.py b/tests/services/test_mfa_provision.py
new file mode 100644
index 0000000..d958533
--- /dev/null
+++ b/tests/services/test_mfa_provision.py
@@ -0,0 +1,99 @@
+"""Tests for the pure path/readiness logic in voxkit.services.mfa_provision.
+
+provision_aligner_env() itself shells out to micromamba and downloads
+packages -- it is exercised by a manual integration run, not this suite.
+"""
+
+import sys
+
+from voxkit.services import mfa_provision
+
+
+def test_bundled_env_path_is_under_storage_root(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "get_storage_root", lambda: tmp_path)
+
+ assert mfa_provision.bundled_env_path() == tmp_path / "mfa-env"
+
+
+def test_mfa_root_dir_is_under_storage_root_and_distinct_from_env(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "get_storage_root", lambda: tmp_path)
+
+ assert mfa_provision.mfa_root_dir() == tmp_path / "mfa-root"
+ assert mfa_provision.mfa_root_dir() != mfa_provision.bundled_env_path()
+
+
+def test_is_aligner_env_ready_false_when_marker_missing(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "get_storage_root", lambda: tmp_path)
+
+ assert mfa_provision.is_aligner_env_ready() is False
+
+
+def test_is_aligner_env_ready_true_when_marker_present(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "get_storage_root", lambda: tmp_path)
+ marker = tmp_path / "mfa-env" / "Scripts" / "mfa-script.py"
+ marker.parent.mkdir(parents=True)
+ marker.write_text("")
+
+ assert mfa_provision.is_aligner_env_ready() is True
+
+
+def test_lockfile_path_none_on_platform_without_a_bundled_lockfile(monkeypatch):
+ monkeypatch.setattr(sys, "platform", "some-unsupported-platform")
+
+ assert mfa_provision.lockfile_path() is None
+
+
+def test_lockfile_path_none_when_file_does_not_exist(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "_bundle_root", lambda: tmp_path)
+ monkeypatch.setattr(sys, "platform", "win32")
+
+ assert mfa_provision.lockfile_path() is None
+
+
+def test_lockfile_path_resolves_when_present(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "_bundle_root", lambda: tmp_path)
+ monkeypatch.setattr(sys, "platform", "win32")
+ lockfile = tmp_path / "config" / "mfa-env" / "aligner-win-64.lock"
+ lockfile.parent.mkdir(parents=True)
+ lockfile.write_text("@EXPLICIT\n")
+
+ assert mfa_provision.lockfile_path() == lockfile
+
+
+def test_vendored_micromamba_path_uses_windows_name_on_win32(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "_bundle_root", lambda: tmp_path)
+ monkeypatch.setattr(sys, "platform", "win32")
+
+ expected = tmp_path / "vendor" / "micromamba" / "micromamba.exe"
+ assert mfa_provision.vendored_micromamba_path() == expected
+
+
+def test_vendored_micromamba_path_uses_unix_name_elsewhere(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "_bundle_root", lambda: tmp_path)
+ monkeypatch.setattr(sys, "platform", "darwin")
+
+ expected = tmp_path / "vendor" / "micromamba" / "micromamba"
+ assert mfa_provision.vendored_micromamba_path() == expected
+
+
+def test_provision_aligner_env_raises_when_micromamba_missing(monkeypatch, tmp_path):
+ monkeypatch.setattr(mfa_provision, "vendored_micromamba_path", lambda: tmp_path / "nope.exe")
+
+ try:
+ mfa_provision.provision_aligner_env()
+ raise AssertionError("expected FileNotFoundError")
+ except FileNotFoundError as exc:
+ assert "micromamba" in str(exc).lower()
+
+
+def test_provision_aligner_env_raises_when_lockfile_missing(monkeypatch, tmp_path):
+ micromamba = tmp_path / "micromamba.exe"
+ micromamba.write_text("")
+ monkeypatch.setattr(mfa_provision, "vendored_micromamba_path", lambda: micromamba)
+ monkeypatch.setattr(mfa_provision, "lockfile_path", lambda: None)
+
+ try:
+ mfa_provision.provision_aligner_env()
+ raise AssertionError("expected FileNotFoundError")
+ except FileNotFoundError as exc:
+ assert "lockfile" in str(exc).lower()
diff --git a/vendor/micromamba/micromamba.exe b/vendor/micromamba/micromamba.exe
new file mode 100644
index 0000000000000000000000000000000000000000..1a6e6d894ca3b2df476422d2a0a99193f34a6846
GIT binary patch
literal 11311104
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 ?1c90cnuoQss?bsp(i+0r0JDDW
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