* feat(audio): lower other audio while dictating Opt-in setting lowers the default PipeWire or PulseAudio sink while the microphone is open, then restores that exact level. It stays off by default. * fix(test): keep pytest.approx working when numpy is mocked Other test modules replace sys.modules["numpy"] with a MagicMock at import and leave it there. pytest.approx then crashes on isinstance(..., numpy.bool_). * fix(audio): restore every channel and do not duck if it cannot be undone A failed restore-point write no longer lowers the sink. PulseAudio puts each channel back, and a dead microphone restores playback instead of waiting for an explicit stop. The recognition test patches the real audio module when an older test left a mock behind. * fix(test): put the audio-feedback mock back after the ducking test The recognition test has to patch the real module, but later tests still expect the mock that test_text_injector installs. Restore that mock when the test ends, and annotate the new helpers. * fix(test): annotate the playback-duck tests AGENTS.md requires a type hint on every new function. These tests were the ones still missing them. * fix(audio): skip arming playback duck after recording already failed * chore: retrigger review after rebase
225 lines
15 KiB
Markdown
225 lines
15 KiB
Markdown
# AGENTS.md — Vocalinux
|
||
|
||
Voice dictation for Linux: GTK 3 tray app (Python) plus a Next.js marketing site in `web/`. Default speech engine is **whisper.cpp** (`pywhispercpp`); OpenAI Whisper, Vosk, and a user-configured remote API are optional. Do not invent features, user counts, or privacy claims.
|
||
|
||
## Commit attribution
|
||
|
||
Agents must not add themselves as commit co-authors or add `Co-authored-by`
|
||
trailers for agents.
|
||
|
||
## Critical: git worktrees for every branch and PR
|
||
|
||
Never create a branch, commit, or open a pull request in the primary checkout. Always use a linked git worktree so the main working tree stays on `main` and stays clean. Do not `git switch` / `git checkout` a feature branch in the primary directory, and do not leave it dirty.
|
||
|
||
```bash
|
||
git fetch origin
|
||
git worktree add /tmp/vocalinux-<task> -b <type>/<short-name> origin/main
|
||
|
||
# All edits, commits, and `gh pr create` happen inside that worktree.
|
||
|
||
git worktree remove /tmp/vocalinux-<task>
|
||
git worktree prune
|
||
```
|
||
|
||
Rules:
|
||
|
||
- One worktree per branch, one branch per PR
|
||
- Place worktrees **outside** the primary working tree (`/tmp/vocalinux-<task>` or a sibling directory such as `../.worktrees/vocalinux-<task>`)
|
||
- Never run two tasks in the same worktree
|
||
- Never commit directly to `main`
|
||
- Clean up the worktree after the PR is pushed
|
||
|
||
## Toolchain
|
||
|
||
| Piece | Current |
|
||
|---|---|
|
||
| Python | `>=3.11` (`requires-python` in `pyproject.toml`; CI: 3.11–3.14). Floor, classifiers, CI matrix and `install.sh` must agree — `tests/test_python_version_policy.py` |
|
||
| GTK | GTK 3 via distro `python3-gi` (PyGObject). Never pip-install it |
|
||
| uv | `>=0.12,<0.13` (`[tool.uv]` in `pyproject.toml`). `uv.lock` is the source of truth; CI runs `uv sync --locked` / `uv run --locked`, which fails on drift |
|
||
| just | https://just.systems or distro package `just` |
|
||
| Format / lint / types | Black + isort (line length 100), flake8 (`E9,F63,F7,F82` only), mypy `src/` (targets 3.11). The three linters live in the `lint` dependency group, not the `dev` extra, so CI can install them without building the project |
|
||
| Website | Next.js / TypeScript in `web/` — see `web/AGENTS.md` |
|
||
|
||
Two virtualenvs, on purpose:
|
||
|
||
- **`.venv/`** — uv's, for dev tooling. `just deps` syncs it; every other Python `just` recipe uses `uv run --no-sync` so it does not prune extras that `just deps-all` installed. No activation needed.
|
||
- **`venv/`** — what `install.sh` builds for the app itself, from the *system* Python so distro `gi` is importable.
|
||
|
||
Never run `install.sh` from an activated `.venv`: it drops an inherited `VIRTUAL_ENV` from `PATH` and picks `$SYSTEM_PYTHON` (default `/usr/bin/python3`) precisely because a venv built from uv's interpreter cannot see distro PyGObject.
|
||
|
||
## Setup
|
||
|
||
```bash
|
||
./install.sh --dev # system deps + venv + editable install + tests
|
||
# non-interactive / no TTY:
|
||
./install.sh --dev --auto
|
||
./install.sh --dev --auto --no-rebuild-whispercpp # skip cmake/Vulkan rebuild
|
||
|
||
source venv/bin/activate
|
||
```
|
||
|
||
Manual venv (must see distro `gi`):
|
||
|
||
```bash
|
||
uv venv --system-site-packages --python /usr/bin/python3
|
||
source venv/bin/activate
|
||
# exclude pygobject from uv sync/export:
|
||
# --no-install-package pygobject / --no-emit-package pygobject
|
||
uv pip install -e ".[dev,vad]"
|
||
```
|
||
|
||
`install.sh` flags: `--engine=whisper_cpp|whisper|vosk|remote_api`, `--test`, `--skip-models`, `--venv-dir=PATH`. Default engine is `whisper_cpp`.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
just lint # flake8 (critical) + black --check + isort --check
|
||
just format # black + isort
|
||
just typecheck # mypy src/
|
||
just test # pytest -v
|
||
just test-cov # pytest --cov=src --cov-report=html
|
||
just deps # sync .venv with dev+vad extras and the lint group
|
||
just deps-all # also whisper/vosk/docs; later recipes use --no-sync so they keep it
|
||
just lock # regenerate uv.lock, requirements/*.txt and the Flatpak dep manifest
|
||
just lock-check # fail if uv.lock is stale vs pyproject.toml
|
||
just flatpak-deps # regenerate the Flatpak dep manifest from requirements/runtime.txt (needs PyPI)
|
||
just flatpak-deps-check # fail if that manifest is behind the export
|
||
just model-checksums # refresh pinned model digests after adding a model
|
||
just appimage # build the AppImage in its pinned base image (needs docker)
|
||
just appimage-boot fedora:42 # boot that AppImage in a distro container
|
||
just aur-gate # build the AUR PKGBUILD on current Arch (needs docker)
|
||
just install-gate debian:12 # run install.sh unattended in a distro container
|
||
just verify-release # check a published release as published (needs gh)
|
||
just pre-commit # pre-commit run --all-files
|
||
just run-debug # vocalinux --debug
|
||
just run-source-debug
|
||
```
|
||
|
||
```bash
|
||
pytest
|
||
pytest tests/test_command_processor.py
|
||
pytest tests/test_command_processor.py::TestCommandProcessor::test_initialization
|
||
pytest -m "not slow"
|
||
pytest -m "not integration"
|
||
python -m vocalinux.main --debug
|
||
```
|
||
|
||
Website: `web/AGENTS.md`, `web/PRODUCT.md`, `web/DESIGN.md`. Do not duplicate site commands here.
|
||
|
||
## Dependencies (uv)
|
||
|
||
`uv.lock` is authoritative. `just lock` regenerates it, the hash-pinned `requirements/*.txt` exports, and `packaging/flatpak/python3-dependencies.yaml`. The AppImage build installs from `runtime`/`vad` plus `appimage.txt`; `install.sh` consumes the runtime and selected extras with `--require-hashes --no-deps --no-build-isolation`, then installs the project with `--no-deps --no-build-isolation`. **Do not edit `requirements/*.txt` by hand**, nor the url/sha256 pairs in `packaging/flatpak/python3-dependencies.yaml`. Change `pyproject.toml` (or `requirements/whisper.in` for the Whisper engine, `requirements/appimage*.in` for the AppImage), run `just lock`, and commit the lock plus the exports with the manifest change.
|
||
|
||
| Constraint | Rule |
|
||
|---|---|
|
||
| PyGObject | Distro `python3-gi` only, via `--system-site-packages`. Pip install fails on Ubuntu 24.04 (`girepository-2.0`). uv-managed interpreters do not see distro `gi` unless the venv is created that way |
|
||
| `[vosk]` extra | Wheel-only on PyPI (no sdist). Never part of a source-buildable lock. `install.sh --engine=vosk` installs it |
|
||
| Whisper CPU torch | `requirements/whisper.txt` is compiled from `requirements/whisper.in`. Pin `torch` to a `+cpu` local version — PyPI CUDA wheels win resolution regardless of index order. OpenAI Whisper does not use `torchaudio`, and the CPU index can lag torch |
|
||
| pywhispercpp | Pinned in `install.sh` as `PYWHISPERCPP_VERSION` (keep in sync with `uv.lock`) |
|
||
| Installer build tools | `[dependency-groups].installer-build` exports to `requirements/installer-build.txt`. Bootstrap uses hashed wheels on new and reused venvs; source builds use these tools without build isolation. Its setuptools `>=83` floor keeps the pinned installer tools patched; do not add it to `[build-system]` (Arch). Backend rebuilds select pywhispercpp's complete hash block from `runtime.txt` via `scripts/installer_requirements.py`, with `--no-deps` so they cannot replace the runtime |
|
||
| Installer Whisper export | Compiled universally from Python 3.11, constrained by `runtime.txt` and `installer-build.txt` to keep shared pins consistent. Uses a `+cpu` torch pin and best-match index resolution so the CPU index's older copies of shared packages do not downgrade the runtime |
|
||
| Remote installer | After cloning, hand off to the tagged `install.sh` with the original arguments and remote venv path. The installer and its exports must come from the same revision, including when the tag predates the pinned installer |
|
||
| `[vad]` extra | `onnxruntime` for Silero VAD |
|
||
| One export per extra | Every extra in `pyproject.toml` needs a hash-pinned `requirements/<extra>.txt` (underscores become dashes), written by a `uv export` line in `just lock`. `install.sh` installs six of them, so an extra with no export is an install path with nothing pinned, which is how `[parakeet]`, `[faster_whisper]` and `[vosk]` reached users. `tests/test_dependency_exports.py` enumerates the extras and the installer's call sites rather than trusting the recipe to stay complete; `docs` is the one exemption and it carries its reason in `EXPORT_EXEMPT` |
|
||
| Exports match the lock | `just export-check` re-runs every `uv export` line in `just lock` and fails if a committed export is behind `uv.lock`. It reads those lines out of the recipe, so an export added there is checked without touching `scripts/check_exports.py`, and it runs under `UV_OFFLINE=1`: `uv export` reads the lock and nothing else. The lint job runs it, because an export named in the recipe and never regenerated is a snapshot, not a pin. `uv pip compile` targets are out of scope by design (they resolve against live indexes) |
|
||
| AppImage PyGObject | Pinned separately in `requirements/appimage.in`, and below 3.52: the AppImage bundles its own interpreter and builds PyGObject against the base image's girepository-1.0, while uv.lock's 3.56 needs girepository-2.0 (glib 2.80+) |
|
||
| AppImage build inputs | Base image, tooling, interpreter, shaderc and the Vulkan headers are pinned in `packaging/appimage/tool_checksums.txt`. Build with `just appimage` (docker) — building on the host ships the host's glibc, which is what kept the AppImage off Debian 12 |
|
||
| AppImage boot matrix | `packaging/appimage/boot-test.sh` runs the finished AppImage in distro containers, and the matrix in `unified-pipeline.yml` must keep distros both older and newer than the build image: the old ones prove the glibc floor, the new ones catch a bundle that breaks the host binaries it spawns. A distro added there needs its package recipe in the script — `tests/test_appimage_packaging.py` checks both |
|
||
| Flatpak deps | `packaging/flatpak/python3-dependencies.yaml` is generated from `requirements/runtime.txt` by `scripts/sync_flatpak_deps.py` (`just flatpak-deps`, which `just lock` runs itself). It resolves each artifact by the digest uv already recorded, so the Flatpak downloads the bytes in `uv.lock`. The project installs there with `pip3 install --no-deps`, so nothing inside the build evaluates `pyproject.toml` — `tests/test_flatpak_packaging.py` checks the invariant offline |
|
||
| Speech models | Verified against digests pinned in `src/vocalinux/utils/model_checksums.txt` before install, by both `install.sh` and the runtime downloaders. **Fails closed** — an unpinned model is refused. Regenerate with `just model-checksums` (never by hand); `tests/test_model_checksums.py` fails if it falls behind. whisper.cpp URLs use a pinned Hugging Face commit, never `main` |
|
||
|
||
Optional extras: `vosk`, `whisper`, `vad`, `dev`.
|
||
|
||
## Layout
|
||
|
||
```
|
||
src/vocalinux/
|
||
├── main.py, version.py, common_types.py
|
||
├── audio/
|
||
│ └── playback_ducker.py # lower default sink while dictating
|
||
├── single_instance.py # $XDG_DATA_HOME/vocalinux/instance.lock
|
||
├── auto_pause_monitor.py # unload model while configured apps run
|
||
├── model_keepalive.py # idle unload
|
||
├── suspend_handler.py # logind PrepareForSleep
|
||
├── speech_recognition/
|
||
│ ├── recognition_manager.py # whisper.cpp / Whisper / Vosk / remote
|
||
│ ├── command_processor.py # voice commands
|
||
│ ├── silero_vad.py
|
||
│ └── data/ # bundled silero_vad.onnx
|
||
├── text_injection/
|
||
│ ├── text_injector.py # X11 / clipboard / xdotool
|
||
│ └── ibus_engine.py # Wayland IBus injection
|
||
├── ui/
|
||
│ ├── tray_indicator.py, settings_dialog.py, first_run_dialog.py
|
||
│ ├── config_manager.py, action_handler.py, audio_feedback.py
|
||
│ ├── autostart_manager.py, keyboard_shortcuts.py
|
||
│ ├── logging_dialog.py, logging_manager.py
|
||
│ └── keyboard_backends/ # pynput (X11), evdev (Wayland)
|
||
├── utils/
|
||
│ ├── paths.py, resource_manager.py
|
||
│ ├── update_checker.py, update_monitor.py
|
||
│ └── whispercpp_model_info.py, vosk_model_info.py
|
||
└── resources/ # SVG icons + WAV cues (also repo resources/)
|
||
```
|
||
|
||
Also: `tests/`, `docs/`, `packaging/` (AppImage, AUR, Flatpak), `scripts/`,
|
||
`install.sh` (bootstrap and orchestration), `install.d/` (sourced installer modules),
|
||
`uninstall.sh`, `web/`.
|
||
|
||
| Task | Start here |
|
||
|---|---|
|
||
| Voice command | `speech_recognition/command_processor.py` |
|
||
| Engine / models | `speech_recognition/recognition_manager.py` |
|
||
| Text injection | `text_injection/text_injector.py`, `ibus_engine.py` |
|
||
| Settings | `ui/config_manager.py`, `ui/settings_dialog.py` |
|
||
| Hotkeys | `ui/keyboard_shortcuts.py`, `ui/keyboard_backends/` |
|
||
|
||
## Style
|
||
|
||
- Type hints on all function signatures. `Protocol` interfaces live in `common_types.py`
|
||
- Imports: stdlib → third-party → local (`isort` black profile)
|
||
- Names: `PascalCase` classes, `snake_case` functions, `UPPER_SNAKE_CASE` constants, `_private` methods
|
||
- `logger = logging.getLogger(__name__)` per module
|
||
- Triple-quoted docstrings on modules, classes, and public functions
|
||
- Specific exceptions; log errors with context
|
||
- `subprocess` calls that run a **host** binary pass `env=host_env()` (`utils/host_process.py`): a host binary inheriting the AppImage's library paths dies on the wrong GLib. The one spawn of our own interpreter (`start_engine_process`) passes the environment untouched — it needs the bundle. `tests/test_host_process.py` walks the AST and fails on either mistake
|
||
- Tests: `tests/test_*.py`, `test_*` functions, `unittest.TestCase` or pytest; `pytest-mock` via `mocker`
|
||
- Markers: `@pytest.mark.slow`, `integration`, `audio` (hardware). Default timeout 10s (`pytest-timeout`)
|
||
|
||
## Git and PRs
|
||
|
||
Conventional Commits: `type(scope): short description` with optional body and `Fixes #123`.
|
||
|
||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
|
||
|
||
```
|
||
feat(commands): add "select all" voice command
|
||
fix(tray): resolve icon not updating on Wayland
|
||
docs(readme): update installation instructions
|
||
```
|
||
|
||
Branch prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`, `release/` (e.g. `release/v0.7.0-beta`).
|
||
|
||
- Never push to `main`. Never commit on `main`
|
||
- Every change goes through a PR (including docs)
|
||
- Wait for CI (`.github/workflows/unified-pipeline.yml`) before asking for merge
|
||
- Squash merge
|
||
- **Do not merge the PR yourself**
|
||
- Fill `.github/PULL_REQUEST_TEMPLATE.md`
|
||
|
||
Releases: follow `docs/RELEASE_PROCESS.md`. Version source of truth is `src/vocalinux/version.py`; sync README, `docs/INSTALL.md`, `docs/UPDATE.md`, `web/src/app/page.tsx`, `web/package.json`. Prep on `release/vX.Y.Z-PHASE`; tag **after** merge.
|
||
|
||
## Website
|
||
|
||
Site-only work: `web/AGENTS.md` (commands, layout map). Product claims: `web/PRODUCT.md`. Visual system: `web/DESIGN.md`. Git/worktree/PR rules are this file — do not duplicate them under `web/`.
|
||
|
||
## Runtime (any Linux dev machine)
|
||
|
||
- Create venvs with `--system-site-packages`. Do not drop that flag
|
||
- Kill the app by PID, never `pkill -f vocalinux` (matches editors, shells, and cwd paths)
|
||
- Stale instance: `$XDG_DATA_HOME/vocalinux/instance.lock` (default `~/.local/share/vocalinux/instance.lock`)
|
||
- Default dictation shortcut: hold Right Alt (push-to-talk). Existing `~/.config/vocalinux/config.json` wins
|
||
- Headless speech smoke: `pywhispercpp.model.Model` + `CommandProcessor` (first run may download the tiny model)
|
||
|
||
Update this file when commands, layout, or agent rules change.
|