A Void Linux user running a Wayland desktop under niri with Dank Material Shell has no packaged, offline voice dictation app that fits that stack. Vocalinux, the upstream Python GTK 3 tray dictation app, treats Void as experimental and untested, offers no xbps package, and makes several assumptions that do not hold on this machine: its CUDA-oriented model recommendation is unreachable on a Vulkan-capable Nvidia card, so an RTX 2050 with 4 GB of VRAM silently gets a small unquantized model instead of one sized for the card; its text-injection and hotkey behaviour under niri is undocumented; and everything user-visible carries the upstream name. The user wants dictation that lands at the cursor in a terminal under niri without clobbering the clipboard, a model default that actually uses their GPU, a signed xbps package they can install and update like any other Void package, and their own product name and branding on the app and the web site — without forking niri or Dank Material Shell, and without losing the ability to take upstream fixes by hand.
Solution
Voculinux is a hard fork of Vocalinux 0.18.5 for Void Linux (glibc, x86_64), Wayland-primary under niri with Dank Material Shell. It stays a Python GTK 3 tray app with whisper.cpp as the one guaranteed speech engine. Everything user-visible renames to voculinux — binaries, desktop identity, configuration and data directories, D-Bus identity, package, and web site — while the internal Python module name stays vocalinux so upstream fixes remain cheap to cherry-pick. A first run imports a legacy Vocalinux configuration once. Dictation under niri is activated from niri's own keybinds calling the app's existing toggle triggers, the tray speaks the standard StatusNotifier protocol so Dank Material Shell hosts it with no shell-specific code, and text reaches the cursor through a clipboard-free-first fallback ladder whose effective first rung on niri is direct typing. On a 4 GB Nvidia card the default model becomes a quantized large turbo model chosen by free VRAM at load time, stepping down automatically when memory is tight or a load fails. Distribution is a signed personal xbps repository, built and signed in CI from version tags, with the package depending on Void's own Python and GTK packages and speech models downloading on first run against pinned checksums. The web site is rebranded in the same effort, using Carbon's structural discipline remapped to the existing house palette.
User Stories
As a Void Linux user, I want to install Voculinux from a signed xbps repository with the normal package tools, so that installing and updating dictation feels like any other Void package.
As a Void Linux user, I want to verify the repository signing key against a fingerprint published in the README, so that I know the packages I install really come from the publisher.
As a Void Linux user, I want the package to use Void's own Python, GTK, and library packages rather than a bundled interpreter, so that security updates arrive through my normal system update.
As a first-time user, I want the speech model to download on first run and be verified against a pinned checksum, so that I get working dictation without trusting an unverified download.
As an existing Vocalinux user moving to Voculinux, I want my existing configuration and custom dictionary copied into the new configuration directory on first run, so that I do not lose my settings in the rename.
As a migrating user, I want the new configuration to always win once it exists and never be merged or written back to the legacy location, so that my settings have exactly one home and no surprise changes.
As a niri user, I want to start and stop dictation from a niri keybind, so that dictation is one keypress away without depending on application-level global hotkey grabbing.
As a niri user, I want clear guidance that a given key is either a compositor bind or an in-app shortcut, never both, so that dictation never double-triggers.
As a push-to-talk user, I want hold-to-talk to keep working through the in-app input backend on niri today, so that I am not blocked on unreleased compositor features.
As a Dank Material Shell user, I want the Voculinux tray icon and menu to appear in my bar like any other StatusNotifier item, so that I can see dictation state and reach settings without shell-specific plugins.
As a terminal user dictating into Ghostty under niri, I want the transcribed text typed at my cursor without my clipboard being overwritten, so that dictation does not destroy what I had copied.
As a terminal user, I want any clipboard-paste fallback to use the terminal paste shortcut automatically, so that fallback dictation still lands in the terminal instead of triggering a search or nothing at all.
As a Wayland user, I want text injection to fall back automatically through alternative mechanisms when the preferred one is unavailable on my compositor, so that dictation degrades instead of silently dropping my words.
As a GNOME or KDE user, I want the one-time portal permission approval remembered across restarts where the desktop supports it, so that I am not asked for keyboard access on every launch.
As a GNOME or KDE user who denies the portal permission dialog, I want my dictation to still be typed through a fallback mechanism, so that saying no costs me a fallback rather than the dictation itself.
As a niri user, I want first dictation to work with no permission dialog at all, so that there is nothing to approve and nothing to deny on my desktop.
As an Nvidia laptop user with 4 GB of VRAM, I want the default speech model sized for my card, so that transcription quality is as high as my hardware comfortably allows.
As an Nvidia laptop user whose GPU is busy driving my display, I want the app to choose its model by free VRAM rather than total VRAM, so that the model still loads while other GPU clients are running.
As a GPU user whose chosen model cannot load, I want the app to step down to a smaller model once and retry before giving up, so that a transient memory shortage does not leave me with a broken engine.
As a user with a complete locally installed CUDA toolkit, I want CUDA preferred over Vulkan, so that my toolkit investment is used; as a user without one, I want Vulkan used directly, so that I am never pushed into installing a toolkit.
As a CPU-only user, I want the same model ladder applied sensibly against system memory, so that dictation still works with no usable GPU.
As an English-only user on a small model rung, I want the English-only variant selected for me, so that I get its accuracy benefit; as a multilingual user, I want the multilingual variant, so that my language keeps working.
As a user who prefers a different model, I want every catalogued model to remain manually selectable even when it is not recommended for my card, so that recommendations guide me without restricting me.
As a privacy-conscious user, I want the guaranteed engine to run fully on my machine, so that my voice audio does not leave the computer during normal dictation.
As an optional-engine user, I want Vosk, OpenAI Whisper, faster-whisper, and Parakeet to remain installable and selectable extras, so that an existing setup keeps working after the fork.
As a remote-API user, I want to keep pointing Voculinux at my own transcription endpoint with my own key, understanding that audio then leaves my machine, so that my existing server-based setup survives.
As a user upgrading with a non-default engine configured, I want my engine choice preserved, so that an upgrade never silently changes how my dictation works.
As a system-audio transcription user, I want application and sink audio offered as clearly labelled system-audio sources, so that I can transcribe what my computer plays.
As a system-audio user whose saved audio source has disappeared, I want a clear error instead of the app silently recording my microphone, so that I never capture the wrong audio by accident.
As a microphone user, I want playback ducking while dictating from the microphone but not while capturing system audio, so that the app never turns down the very audio it is transcribing.
As a settings user, I want the renamed configuration location, desktop entry, and autostart entry to behave exactly as before under the new name, so that the rename changes identity without changing behaviour.
As a scripting user, I want to toggle, start, and stop dictation through the command line and D-Bus, and to read the current dictation state, so that binds, bars, and scripts can integrate without scraping the UI.
As a web site visitor, I want the Voculinux site to carry the new name and branding with the same honest capability claims as today, so that what the site promises matches what the app does.
As a design-conscious user, I want the app to keep following my operating system theme with only a small brand accent, so that Voculinux looks at home on my desktop rather than imposing a foreign theme.
As the fork maintainer, I want upstream fixes ported one at a time at defined sync points with a freeze once a release branch opens, so that the fork stays current without destabilising releases.
As the release operator, I want every version tag to produce a full signed repository snapshot from CI with no hand-built assets, so that a release is reproducible from the tag alone and an unsigned repository can never be published.
As the release operator, I want a release to fail rather than publish when the signing key is missing, so that users are never offered an uninstallable unsigned repository.
As a future contributor, I want performance budgets measured against a fixed protocol on the target machine, so that claims about memory use, wake latency, and transcription speed are numbers rather than impressions.
Implementation Decisions
Stack and scope posture
Voculinux stays on Python with the existing GTK 3 tray application. A wholesale Rust rebuild is rejected: the hard problems (Wayland injection, global hotkeys) are compositor-protocol limits that a language change does not move, Rust would force a simultaneous GTK 4 migration, and inference already runs in native code. Incremental Rust spikes are not part of this spec.
Wayland is primary. The target desktop is niri with Dank Material Shell; neither is forked or targeted through internal APIs. Integration uses only standard seams: compositor spawn keybinds, the StatusNotifier tray protocol, the app's own D-Bus interface, and the existing compositor-agnostic injection and input backends.
Rename and first-run import
Everything user-visible renames to Voculinux/voculinux: application name, console and GUI entry points, desktop file and desktop identity, autostart entry, tray identity and icon names, IBus engine identity, system resource install prefixes, the xbps package name, the web site, user-visible setting and environment override names, and the outbound user-agent string.
Configuration, data, cache, state, and IBus data move together under the new application directory name, driven from the single existing application-directory constant rather than scattered path edits. This carries the configuration file, transcription history, logs, and the single-instance lock.
The D-Bus bus name, object path, and interface move to the voculinux namespace with no compatibility alias for the old name. This is a hard fork; keybinds and scripts target the new name.
The Python import package deliberately stays vocalinux as a retained internal name, documented as such. Upstream URLs and changelog history stay untouched for provenance. This split keeps hand cherry-picks from upstream nearly conflict-free, with conflicts concentrated at the renamed user-visible surfaces.
One-time import: on first run, when the new configuration directory has no configuration file but the legacy one exists, the legacy configuration, the legacy custom dictionary, and the persisted portal restore token are copied into the new locations and the import is logged. The existing configuration normalisation then loads the copy unchanged. The new directory always wins once it exists; there is never a merge, never a write-back to the legacy path, and never a second import. Models, history, logs, and locks are not migrated; models re-resolve through the pinned-checksum download path. With no legacy configuration, the app starts from fresh defaults.
Engine promise
whisper.cpp is the only guaranteed engine: the default, the one whose models are checksum-pinned, the one packaged for Void, and the one all performance budgets and GPU decisions are measured against.
Vosk, OpenAI Whisper, faster-whisper, and Parakeet remain best-effort installable extras: selectable and installable, never vendored into the xbps package, outside the performance budgets, and fixed opportunistically rather than as release blockers. The remote API engine remains best-effort and user-configured: the user supplies endpoint and key, no server is shipped, and using it sends audio off the machine.
No engine is dropped, and upgrade installs keep preserving an already-configured engine. In this spec, "guaranteed" means packaged, checksum-pinned, and budgeted; "best-effort" means installable and selectable but unbudgeted.
Model selection on 4 GB-class hardware
The recommended catalog is a three-rung ladder: the quantized large turbo model at the higher quantization as default, the same model one quantization step down as the first fallback, and the small model (English-only variant for English-only users, multilingual otherwise) as the floor. Unquantized turbo, the full large family, and the medium family are excluded from the recommended set on 4 GB cards; everything in the catalog remains manually selectable and checksum-pinned as today.
Load-time selection keys to free memory measured at load — free VRAM on a discrete GPU, free system RAM on integrated graphics or CPU-only machines — never to total memory. The recommendation must key to the backend the installed build actually has plus measured free memory, fixing today's behaviour where an informational backend probe makes the VRAM-aware recommendation unreachable on Vulkan-capable Nvidia cards.
Backend order is conditional: with a complete, architecture-matching CUDA toolkit already installed, CUDA then Vulkan then CPU; without one, Vulkan then CPU. The installer never installs a CUDA toolkit. The same ladder applies under Vulkan, which is the packaged configuration.
On a model-load failure such as GPU out-of-memory, the app steps one rung down and retries once before surfacing the error. This automatic step-down does not exist today and is built as part of this work.
The free-memory thresholds between rungs are rule shape only until calibrated. They are hard-coded only after the installed-build performance measurement (below) records real load footprints on the target card.
Capture
The existing PipeWire capture scheme is kept unchanged and remains the default capture path. System-audio sources are sink monitors presented as clearly labelled system-audio entries alongside microphones, resolved by stable saved names rather than positions, and captured as raw audio through PipeWire's command-line tools.
There is deliberately no silent cross-family fallback: a saved system-audio selection that no longer resolves raises an error instead of recording a microphone. PipeWire's absence degrades the app to microphone-only without breaking microphone enumeration. Playback ducking stays off while a system-audio source is being captured.
The xbps package must therefore pull in or recommend PipeWire's command-line tools; without them the packaged app is microphone-only, exactly as an install without those tools behaves today.
Text injection and the portal
The injection fallback order is: RemoteDesktop portal key injection first where a portal backend implements it, then direct keysym typing, then the virtual-input tool path, then XWayland key injection for XWayland windows only, then clipboard copy with a manual-paste notification as the last resort. The first two rungs never touch the clipboard.
On niri no portal backend implements RemoteDesktop, so the portal is never selected, no permission prompt exists, and the effective order starts at direct typing. Product copy and documentation state the niri first-run experience plainly as "no prompt; typing goes direct" and never pin the portal as a niri recommendation. IBus injection stays excluded on niri because it can report success while text is silently dropped there.
Where a portal does exist, the permission handshake stays lazy: no prompt at startup, the dialog appears at first actual injection, and it requests keyboard access only. A granted session's restore token keeps being persisted owner-only so later launches skip the prompt where the desktop supports persistence; a denial closes the unfinished session, is not retried within that session, caches nothing negative, demotes the portal for the run, and types the pending text through the next fallback.
Terminal handling stays as today: known terminals are detected through the focused-window probe (which already works on niri), clipboard-paste paths use the terminal paste shortcut in terminals with a settings override, and injection always waits for held modifiers to be released first so a push-to-talk modifier cannot corrupt the delivered keys. The per-machine backend pin setting and environment override remain as escape hatches under the renamed names.
Activation, tray, and D-Bus state
Activation on niri is documentation and configuration, not code: a documented niri keybind snippet (ideally as an optional include file) spawns the existing toggle/start/stop command-line triggers, which forward to the D-Bus interface and exit. The snippet sets the bind non-repeating and warns that a key is either a compositor bind or an in-app shortcut, never both, because both would fire.
Hold-to-talk stays on the in-app evdev backend. Compositor press/release binds are not depended on and not documented as supported until a numbered niri release ships them.
The tray remains a standard StatusNotifier item through the existing application-indicator stack, hosted by Dank Material Shell or any conforming watcher with no shell-specific code. One verification is owed on real hardware: the app currently probes for one exact watcher bus name before warning that the tray may not appear. If the Dank Material Shell watcher owns only the freedesktop watcher name, the probe is widened to accept both names; nothing changes if hardware shows the current probe correct.
The D-Bus interface gains a dictation state signal/property (idle, listening, processing) alongside the existing toggle/start/stop methods — renamed to the voculinux namespace — so bars, widgets, and scripts can reflect state without scraping. A Dank Material Shell bar plugin remains a later optional extra built on that signal, outside the core package and outside this spec's deliverables.
Branding and the web site
The visual direction is Carbon-mapped, not Carbon-themed: Carbon's spacing scale, type roles, motion, and contrast discipline are adopted, but colour tokens point at the existing house palette — the current teal stays the interactive colour, the site canvas stays warm paper, red stays reserved for recording and destructive actions, and Carbon's blue and typeface are not adopted. No remote webfonts; existing system type stacks satisfy the type roles.
Bongbetic black/white-and-red branding is restricted to attribution chrome (header and footer lockup). It does not re-skin the site or the app. Brand binary assets are never vendored into the repository; public surfaces link the hosted copies.
The GTK app keeps following operating-system theme variables exactly as today, adding only a small accent hook for recording state and the primary action in the house teal. Dictation overlay signal colours are unchanged. There is no Carbon component port into the GTK app.
The web site rebrand is part of this effort, not a later one, and keeps the existing honesty constraints: no invented user counts, testimonials, features, or privacy claims beyond what the app actually does.
Void packaging
Distribution is a signed personal xbps repository first. Submission to upstream void-packages is out of scope for this spec. Only glibc x86_64 is published; musl and other architectures are out of scope.
Three package templates live in the repository in void-packages layout under the packaging tree — the application and the two Python dependencies Void does not ship (the input library and the whisper.cpp binding) — so they build with the standard Void source build tooling and are reviewed through the repository's normal pull-request flow. The application template consumes the canonical release source distribution, checksum-verified, not a git archive.
Packaging is thin: interpreter, GTK, PyGObject, and every Void-packaged Python dependency come from Void; only the two unpackaged dependencies are built from source under their own templates. There is no pip at build or install time, and the installer's venv arrangement does not apply to a system package. Package versions float on Void's versions within the project's declared ranges; the release smoke test is the drift net.
The whisper.cpp binding is built with the Vulkan backend and portable CPU flags (shared libraries, no host-native tuning), with Vulkan exposed as a build option that source builders can turn off for a CPU-only rebuild. Its version is pinned to the version the project's lockfile and installer already pin, not to the newest upstream release. No CUDA package is produced: Void has no CUDA toolkit package. A user who installs a toolkit themselves may rebuild the binding locally with CUDA as a documented, unsupported escape hatch.
The one known version gap — Void's audio binding package being older than the project's declared floor — is resolved deliberately and visibly, by carrying an updated local template for the personal repository first, never by disabling dependency verification to hide it. Relaxing the project's floor happens only if investigation shows the floor was raised without need.
Speech models are never package payload. They download on first run against pinned checksums, failing closed on an unpinned model, exactly as today. Optional engines are not in the package and cannot be expressed as optional dependencies in xbps; the package's install message and the README state instead how to add them manually, note the input-group requirement for the evdev hotkey backend, note that models download on first run, and note that one injection helper tool is not yet packaged for Void so that fallback rung is simply absent there.
The stale entries in the project's distro package inventory for Void (a renamed indicator library, an absent injection tool) are corrected as part of bringing the inventory in line with the templates, after verification against a real Void system.
Void has no systemd; anything that would assume a user service uses the XDG autostart path instead.
Release pipeline and upstream intake
Releases stay tag-driven on the existing version tag form, with the tag required to match the version source of truth. A dedicated xbps release workflow runs on tag push inside the pinned Void glibc container, builds with the Void source tooling, and attaches the package, its signature, and the full signed repository data to the release. Every release carries the complete signed repository data because the repository URL serves the newest release's downloads.
Signing uses one RSA 4096 key, generated once on a trusted machine, never committed, exposed to CI only as a secret. The workflow fails rather than publishing anything unsigned when the key is unavailable. The key fingerprint is published in the README next to the repository-registration instructions and Void's own third-party-repository caveat. Pushing the tag itself remains a human act requiring explicit approval each time, and no release asset is ever built or uploaded by hand.
The post-release template version/checksum bump goes through the normal pull-request flow like every other change, never as a bot commit to the main branch.
Upstream fixes are taken as sync-point hand cherry-picks: at each release-preparation cycle and on demand for critical fixes (crash, data loss, security), never as continuous tracking and never as wholesale merges. Intake freezes once a release branch opens, admitting only justified release-blockers. Each port is its own pull request through the standard test, lint, and type gates, referencing both the upstream and fork numbers. Conflicts resolve by keeping upstream logic and re-applying the fork's renames on top. Fixes and user-instruction-changing documentation qualify for porting; features are re-decided per release, and dependency or CI churn is ported only when a security advisory names it.
Performance baseline
Budgets are measured against a fixed three-metric protocol on the target machine with the default model from the ladder above: idle tray memory with the model unloaded (after the idle unload timeout) and loaded; wake latency from toggle to capture start; and transcription real-time factor on a fixed thirty-second sample per backend. Injection latency for the portal path versus direct typing is measured as part of the same pass.
The protocol is already defined; the numbers are not yet taken, because taking them needs an installed build. Measuring them on the first installed build is an early implementation task, and it gates two things: calibration of the model-ladder memory thresholds, and any numeric performance budget the project later commits to. Until then, no performance number is claimed anywhere.
Testing Decisions
A good test here asserts external behaviour — what a user, a desktop, a package manager, or a later maintainer can observe — never internal structure. Tests name behaviour in the project's own vocabulary, run under the suite's existing conventions (per-file test modules, standard markers, the ten-second default timeout), and prefer the highest existing seam over any new one. Four seams cover this spec.
Seam 1, the existing Python behaviour suite, is primary. It covers: the rename invariants (new directory, desktop, D-Bus, and binary identities; retained internal module name); the one-time import rules (import happens once, new configuration wins, no merge, no write-back, restore token carried); model selection (rung chosen by mocked free-memory readings, language-variant pairing, manual selections untouched); the conditional backend order (toolkit present versus absent); the one-time step-down retry on load failure; capture-source selection and its refusal to fall back silently across families; injection ordering, terminal paste-shortcut choice, modifier-release waiting, portal denial and session-cleanup behaviour; the engine-promise surface (guaranteed default, extras detected and selectable, configured engine preserved); the tray watcher probe accepting the watcher names hardware proves real; and the new D-Bus state signal following dictation state transitions. Prior art: the current suite already tests these areas behaviourally — portal cancellation semantics, PipeWire source resolution, engine availability, configuration loading, focused-window detection — and new tests extend those patterns rather than inventing new harnesses.
Seam 2, the existing offline policy-test pattern, covers repository consistency that must never drift: the xbps templates (package name, dependency list reconciled against the project's declared dependency ranges, renamed install paths, Vulkan build option, pinned binding version); the release workflow's shape (version-tag gate, signing steps present, failure when the signing secret is absent, full repository data attached, template bump by pull request); a user-visible naming sweep proving no old-name strings remain on user surfaces outside the allowlist (internal module name, upstream URLs, historical changelog); and the existing checksum-pin and generated-file consistency checks, which keep passing unchanged. Prior art: the suite's current packaging and policy tests for the other distribution formats, which validate generated artifacts and workflow wiring offline without building anything.
Seam 3, the container package smoke, covers the packaged artifact as users receive it. Using podman with the pinned Void glibc container: the templates pass the Void template linter, the packages build, the application installs into a clean Void environment, the renamed binary reports its version, core imports (desktop bindings, the whisper.cpp binding) load, and the signed repository data verifies against the fingerprint the README publishes. Prior art: the existing container gates that boot or install the other packaged formats in pinned distro containers and run the same version-plus-import smoke.
Seam 4, a manual hardware acceptance checklist on the target machine class (Void, niri, Dank Material Shell, RTX 2050), covers what no container can: the tray renders under the shell and the watcher-name question is settled by observation; the niri keybind toggles dictation with acceptable latency and the documented double-trigger configuration is proven to misbehave only when misconfigured as warned; dictation types into Ghostty via direct typing with the clipboard untouched; hold-to-talk works through the input backend; the portal probe result on niri is recorded; models load on CUDA and Vulkan at the expected rungs; and the performance protocol numbers are taken and recorded on the relevant ticket. Where a GNOME or KDE machine is available, the checklist also covers the portal grant, token persistence across restarts, and deny-then-fallback behaviour. Checklist results are recorded as evidence on the implementation tickets, not asserted in CI.
Out of Scope
A Rust rebuild of the application, in whole or as a sidecar, and any GTK 4 migration.
musl builds, non-x86_64 architectures, and submission of any package to upstream void-packages.
Forking niri or Dank Material Shell, and depending on their internal or unreleased APIs — including compositor-driven press/release push-to-talk until a numbered niri release ships it.
A Dank Material Shell bar plugin as a deliverable of this spec (the D-Bus state signal that unblocks it is in scope; the plugin is a later optional extra), and any web-UI rewrite of the desktop app.
A CUDA build inside the xbps package, bundling speech models into any package, and packaging the one injection helper tool Void lacks (its absence simply removes that fallback rung on Void).
Dropping, replacing, or promoting any optional engine, and per-application (rather than per-sink) system-audio capture.
Changing upstream-facing artefacts: the internal Python module name, upstream URLs, and existing behaviour on non-Void distributions beyond what the rename itself touches.
Where an earlier research ticket and a later decision ticket disagree, the later decision wins and this spec follows it: release tags keep the existing version-tag form rather than a name-prefixed form; templates live under the packaging tree with the voculinux name; only glibc x86_64 is published even though the template route could produce musl; the portal rung is described as a GNOME/KDE rung with niri starting at direct typing; and the one-time import also carries the portal restore token, a refinement the final ticket added to the rename decision. Two smaller items are derived rather than explicitly ticketed, and are stated here so they can be challenged: the backend-override environment name renames with the other user-visible names and gets no legacy alias, consistent with the D-Bus decision; and reusing the existing Bongbetic repository signing key satisfies the single-key rule, so users pin one fingerprint across repositories.
Three claims in this spec rest on documentation and code reading rather than hardware observation, and the acceptance checklist exists to close them: the StatusNotifier watcher bus name the shell actually owns, the absence of any RemoteDesktop portal backend on niri, and relative model speed on the exact target GPU (the default rung was chosen on guaranteed memory fit, not a benchmark). The performance numbers and the model-ladder memory thresholds are likewise deferred to the first installed build, as the baseline decision recorded; until they are measured they are protocol, not results.
One minor product behaviour is consciously left to implementation: the app's built-in update checker points at upstream-style releases, while packaged updates should arrive through the package manager. The documentation will say updates come through xbps; whether the checker is adjusted for packaged installs is decided during the packaging work if it proves to misdirect users.
The Carbon direction was settled against a throwaway three-variant web prototype whose branch could not be pushed (the upstream remote rejected it); its findings are recorded in full on the UI direction ticket, and the prototype itself must never merge. The repository has no glossary or architecture-decision records yet; the domain terms this spec relies on — guaranteed versus best-effort engines, the model ladder and its rungs, the retained internal module name, Carbon token mapping as structure-not-palette — are defined in the tickets and summarised above, and remain candidates for a future glossary when one is started.
## Problem Statement
A Void Linux user running a Wayland desktop under niri with Dank Material Shell has no packaged, offline voice dictation app that fits that stack. Vocalinux, the upstream Python GTK 3 tray dictation app, treats Void as experimental and untested, offers no xbps package, and makes several assumptions that do not hold on this machine: its CUDA-oriented model recommendation is unreachable on a Vulkan-capable Nvidia card, so an RTX 2050 with 4 GB of VRAM silently gets a small unquantized model instead of one sized for the card; its text-injection and hotkey behaviour under niri is undocumented; and everything user-visible carries the upstream name. The user wants dictation that lands at the cursor in a terminal under niri without clobbering the clipboard, a model default that actually uses their GPU, a signed xbps package they can install and update like any other Void package, and their own product name and branding on the app and the web site — without forking niri or Dank Material Shell, and without losing the ability to take upstream fixes by hand.
## Solution
Voculinux is a hard fork of Vocalinux 0.18.5 for Void Linux (glibc, x86_64), Wayland-primary under niri with Dank Material Shell. It stays a Python GTK 3 tray app with whisper.cpp as the one guaranteed speech engine. Everything user-visible renames to voculinux — binaries, desktop identity, configuration and data directories, D-Bus identity, package, and web site — while the internal Python module name stays vocalinux so upstream fixes remain cheap to cherry-pick. A first run imports a legacy Vocalinux configuration once. Dictation under niri is activated from niri's own keybinds calling the app's existing toggle triggers, the tray speaks the standard StatusNotifier protocol so Dank Material Shell hosts it with no shell-specific code, and text reaches the cursor through a clipboard-free-first fallback ladder whose effective first rung on niri is direct typing. On a 4 GB Nvidia card the default model becomes a quantized large turbo model chosen by free VRAM at load time, stepping down automatically when memory is tight or a load fails. Distribution is a signed personal xbps repository, built and signed in CI from version tags, with the package depending on Void's own Python and GTK packages and speech models downloading on first run against pinned checksums. The web site is rebranded in the same effort, using Carbon's structural discipline remapped to the existing house palette.
## User Stories
1. As a Void Linux user, I want to install Voculinux from a signed xbps repository with the normal package tools, so that installing and updating dictation feels like any other Void package.
2. As a Void Linux user, I want to verify the repository signing key against a fingerprint published in the README, so that I know the packages I install really come from the publisher.
3. As a Void Linux user, I want the package to use Void's own Python, GTK, and library packages rather than a bundled interpreter, so that security updates arrive through my normal system update.
4. As a first-time user, I want the speech model to download on first run and be verified against a pinned checksum, so that I get working dictation without trusting an unverified download.
5. As an existing Vocalinux user moving to Voculinux, I want my existing configuration and custom dictionary copied into the new configuration directory on first run, so that I do not lose my settings in the rename.
6. As a migrating user, I want the new configuration to always win once it exists and never be merged or written back to the legacy location, so that my settings have exactly one home and no surprise changes.
7. As a niri user, I want to start and stop dictation from a niri keybind, so that dictation is one keypress away without depending on application-level global hotkey grabbing.
8. As a niri user, I want clear guidance that a given key is either a compositor bind or an in-app shortcut, never both, so that dictation never double-triggers.
9. As a push-to-talk user, I want hold-to-talk to keep working through the in-app input backend on niri today, so that I am not blocked on unreleased compositor features.
10. As a Dank Material Shell user, I want the Voculinux tray icon and menu to appear in my bar like any other StatusNotifier item, so that I can see dictation state and reach settings without shell-specific plugins.
11. As a terminal user dictating into Ghostty under niri, I want the transcribed text typed at my cursor without my clipboard being overwritten, so that dictation does not destroy what I had copied.
12. As a terminal user, I want any clipboard-paste fallback to use the terminal paste shortcut automatically, so that fallback dictation still lands in the terminal instead of triggering a search or nothing at all.
13. As a Wayland user, I want text injection to fall back automatically through alternative mechanisms when the preferred one is unavailable on my compositor, so that dictation degrades instead of silently dropping my words.
14. As a GNOME or KDE user, I want the one-time portal permission approval remembered across restarts where the desktop supports it, so that I am not asked for keyboard access on every launch.
15. As a GNOME or KDE user who denies the portal permission dialog, I want my dictation to still be typed through a fallback mechanism, so that saying no costs me a fallback rather than the dictation itself.
16. As a niri user, I want first dictation to work with no permission dialog at all, so that there is nothing to approve and nothing to deny on my desktop.
17. As an Nvidia laptop user with 4 GB of VRAM, I want the default speech model sized for my card, so that transcription quality is as high as my hardware comfortably allows.
18. As an Nvidia laptop user whose GPU is busy driving my display, I want the app to choose its model by free VRAM rather than total VRAM, so that the model still loads while other GPU clients are running.
19. As a GPU user whose chosen model cannot load, I want the app to step down to a smaller model once and retry before giving up, so that a transient memory shortage does not leave me with a broken engine.
20. As a user with a complete locally installed CUDA toolkit, I want CUDA preferred over Vulkan, so that my toolkit investment is used; as a user without one, I want Vulkan used directly, so that I am never pushed into installing a toolkit.
21. As a CPU-only user, I want the same model ladder applied sensibly against system memory, so that dictation still works with no usable GPU.
22. As an English-only user on a small model rung, I want the English-only variant selected for me, so that I get its accuracy benefit; as a multilingual user, I want the multilingual variant, so that my language keeps working.
23. As a user who prefers a different model, I want every catalogued model to remain manually selectable even when it is not recommended for my card, so that recommendations guide me without restricting me.
24. As a privacy-conscious user, I want the guaranteed engine to run fully on my machine, so that my voice audio does not leave the computer during normal dictation.
25. As an optional-engine user, I want Vosk, OpenAI Whisper, faster-whisper, and Parakeet to remain installable and selectable extras, so that an existing setup keeps working after the fork.
26. As a remote-API user, I want to keep pointing Voculinux at my own transcription endpoint with my own key, understanding that audio then leaves my machine, so that my existing server-based setup survives.
27. As a user upgrading with a non-default engine configured, I want my engine choice preserved, so that an upgrade never silently changes how my dictation works.
28. As a system-audio transcription user, I want application and sink audio offered as clearly labelled system-audio sources, so that I can transcribe what my computer plays.
29. As a system-audio user whose saved audio source has disappeared, I want a clear error instead of the app silently recording my microphone, so that I never capture the wrong audio by accident.
30. As a microphone user, I want playback ducking while dictating from the microphone but not while capturing system audio, so that the app never turns down the very audio it is transcribing.
31. As a settings user, I want the renamed configuration location, desktop entry, and autostart entry to behave exactly as before under the new name, so that the rename changes identity without changing behaviour.
32. As a scripting user, I want to toggle, start, and stop dictation through the command line and D-Bus, and to read the current dictation state, so that binds, bars, and scripts can integrate without scraping the UI.
33. As a web site visitor, I want the Voculinux site to carry the new name and branding with the same honest capability claims as today, so that what the site promises matches what the app does.
34. As a design-conscious user, I want the app to keep following my operating system theme with only a small brand accent, so that Voculinux looks at home on my desktop rather than imposing a foreign theme.
35. As the fork maintainer, I want upstream fixes ported one at a time at defined sync points with a freeze once a release branch opens, so that the fork stays current without destabilising releases.
36. As the release operator, I want every version tag to produce a full signed repository snapshot from CI with no hand-built assets, so that a release is reproducible from the tag alone and an unsigned repository can never be published.
37. As the release operator, I want a release to fail rather than publish when the signing key is missing, so that users are never offered an uninstallable unsigned repository.
38. As a future contributor, I want performance budgets measured against a fixed protocol on the target machine, so that claims about memory use, wake latency, and transcription speed are numbers rather than impressions.
## Implementation Decisions
### Stack and scope posture
- Voculinux stays on Python with the existing GTK 3 tray application. A wholesale Rust rebuild is rejected: the hard problems (Wayland injection, global hotkeys) are compositor-protocol limits that a language change does not move, Rust would force a simultaneous GTK 4 migration, and inference already runs in native code. Incremental Rust spikes are not part of this spec.
- Wayland is primary. The target desktop is niri with Dank Material Shell; neither is forked or targeted through internal APIs. Integration uses only standard seams: compositor spawn keybinds, the StatusNotifier tray protocol, the app's own D-Bus interface, and the existing compositor-agnostic injection and input backends.
### Rename and first-run import
- Everything user-visible renames to Voculinux/voculinux: application name, console and GUI entry points, desktop file and desktop identity, autostart entry, tray identity and icon names, IBus engine identity, system resource install prefixes, the xbps package name, the web site, user-visible setting and environment override names, and the outbound user-agent string.
- Configuration, data, cache, state, and IBus data move together under the new application directory name, driven from the single existing application-directory constant rather than scattered path edits. This carries the configuration file, transcription history, logs, and the single-instance lock.
- The D-Bus bus name, object path, and interface move to the voculinux namespace with no compatibility alias for the old name. This is a hard fork; keybinds and scripts target the new name.
- The Python import package deliberately stays `vocalinux` as a retained internal name, documented as such. Upstream URLs and changelog history stay untouched for provenance. This split keeps hand cherry-picks from upstream nearly conflict-free, with conflicts concentrated at the renamed user-visible surfaces.
- One-time import: on first run, when the new configuration directory has no configuration file but the legacy one exists, the legacy configuration, the legacy custom dictionary, and the persisted portal restore token are copied into the new locations and the import is logged. The existing configuration normalisation then loads the copy unchanged. The new directory always wins once it exists; there is never a merge, never a write-back to the legacy path, and never a second import. Models, history, logs, and locks are not migrated; models re-resolve through the pinned-checksum download path. With no legacy configuration, the app starts from fresh defaults.
### Engine promise
- whisper.cpp is the only guaranteed engine: the default, the one whose models are checksum-pinned, the one packaged for Void, and the one all performance budgets and GPU decisions are measured against.
- Vosk, OpenAI Whisper, faster-whisper, and Parakeet remain best-effort installable extras: selectable and installable, never vendored into the xbps package, outside the performance budgets, and fixed opportunistically rather than as release blockers. The remote API engine remains best-effort and user-configured: the user supplies endpoint and key, no server is shipped, and using it sends audio off the machine.
- No engine is dropped, and upgrade installs keep preserving an already-configured engine. In this spec, "guaranteed" means packaged, checksum-pinned, and budgeted; "best-effort" means installable and selectable but unbudgeted.
### Model selection on 4 GB-class hardware
- The recommended catalog is a three-rung ladder: the quantized large turbo model at the higher quantization as default, the same model one quantization step down as the first fallback, and the small model (English-only variant for English-only users, multilingual otherwise) as the floor. Unquantized turbo, the full large family, and the medium family are excluded from the recommended set on 4 GB cards; everything in the catalog remains manually selectable and checksum-pinned as today.
- Load-time selection keys to free memory measured at load — free VRAM on a discrete GPU, free system RAM on integrated graphics or CPU-only machines — never to total memory. The recommendation must key to the backend the installed build actually has plus measured free memory, fixing today's behaviour where an informational backend probe makes the VRAM-aware recommendation unreachable on Vulkan-capable Nvidia cards.
- Backend order is conditional: with a complete, architecture-matching CUDA toolkit already installed, CUDA then Vulkan then CPU; without one, Vulkan then CPU. The installer never installs a CUDA toolkit. The same ladder applies under Vulkan, which is the packaged configuration.
- On a model-load failure such as GPU out-of-memory, the app steps one rung down and retries once before surfacing the error. This automatic step-down does not exist today and is built as part of this work.
- The free-memory thresholds between rungs are rule shape only until calibrated. They are hard-coded only after the installed-build performance measurement (below) records real load footprints on the target card.
### Capture
- The existing PipeWire capture scheme is kept unchanged and remains the default capture path. System-audio sources are sink monitors presented as clearly labelled system-audio entries alongside microphones, resolved by stable saved names rather than positions, and captured as raw audio through PipeWire's command-line tools.
- There is deliberately no silent cross-family fallback: a saved system-audio selection that no longer resolves raises an error instead of recording a microphone. PipeWire's absence degrades the app to microphone-only without breaking microphone enumeration. Playback ducking stays off while a system-audio source is being captured.
- The xbps package must therefore pull in or recommend PipeWire's command-line tools; without them the packaged app is microphone-only, exactly as an install without those tools behaves today.
### Text injection and the portal
- The injection fallback order is: RemoteDesktop portal key injection first where a portal backend implements it, then direct keysym typing, then the virtual-input tool path, then XWayland key injection for XWayland windows only, then clipboard copy with a manual-paste notification as the last resort. The first two rungs never touch the clipboard.
- On niri no portal backend implements RemoteDesktop, so the portal is never selected, no permission prompt exists, and the effective order starts at direct typing. Product copy and documentation state the niri first-run experience plainly as "no prompt; typing goes direct" and never pin the portal as a niri recommendation. IBus injection stays excluded on niri because it can report success while text is silently dropped there.
- Where a portal does exist, the permission handshake stays lazy: no prompt at startup, the dialog appears at first actual injection, and it requests keyboard access only. A granted session's restore token keeps being persisted owner-only so later launches skip the prompt where the desktop supports persistence; a denial closes the unfinished session, is not retried within that session, caches nothing negative, demotes the portal for the run, and types the pending text through the next fallback.
- Terminal handling stays as today: known terminals are detected through the focused-window probe (which already works on niri), clipboard-paste paths use the terminal paste shortcut in terminals with a settings override, and injection always waits for held modifiers to be released first so a push-to-talk modifier cannot corrupt the delivered keys. The per-machine backend pin setting and environment override remain as escape hatches under the renamed names.
### Activation, tray, and D-Bus state
- Activation on niri is documentation and configuration, not code: a documented niri keybind snippet (ideally as an optional include file) spawns the existing toggle/start/stop command-line triggers, which forward to the D-Bus interface and exit. The snippet sets the bind non-repeating and warns that a key is either a compositor bind or an in-app shortcut, never both, because both would fire.
- Hold-to-talk stays on the in-app evdev backend. Compositor press/release binds are not depended on and not documented as supported until a numbered niri release ships them.
- The tray remains a standard StatusNotifier item through the existing application-indicator stack, hosted by Dank Material Shell or any conforming watcher with no shell-specific code. One verification is owed on real hardware: the app currently probes for one exact watcher bus name before warning that the tray may not appear. If the Dank Material Shell watcher owns only the freedesktop watcher name, the probe is widened to accept both names; nothing changes if hardware shows the current probe correct.
- The D-Bus interface gains a dictation state signal/property (idle, listening, processing) alongside the existing toggle/start/stop methods — renamed to the voculinux namespace — so bars, widgets, and scripts can reflect state without scraping. A Dank Material Shell bar plugin remains a later optional extra built on that signal, outside the core package and outside this spec's deliverables.
### Branding and the web site
- The visual direction is Carbon-mapped, not Carbon-themed: Carbon's spacing scale, type roles, motion, and contrast discipline are adopted, but colour tokens point at the existing house palette — the current teal stays the interactive colour, the site canvas stays warm paper, red stays reserved for recording and destructive actions, and Carbon's blue and typeface are not adopted. No remote webfonts; existing system type stacks satisfy the type roles.
- Bongbetic black/white-and-red branding is restricted to attribution chrome (header and footer lockup). It does not re-skin the site or the app. Brand binary assets are never vendored into the repository; public surfaces link the hosted copies.
- The GTK app keeps following operating-system theme variables exactly as today, adding only a small accent hook for recording state and the primary action in the house teal. Dictation overlay signal colours are unchanged. There is no Carbon component port into the GTK app.
- The web site rebrand is part of this effort, not a later one, and keeps the existing honesty constraints: no invented user counts, testimonials, features, or privacy claims beyond what the app actually does.
### Void packaging
- Distribution is a signed personal xbps repository first. Submission to upstream void-packages is out of scope for this spec. Only glibc x86_64 is published; musl and other architectures are out of scope.
- Three package templates live in the repository in void-packages layout under the packaging tree — the application and the two Python dependencies Void does not ship (the input library and the whisper.cpp binding) — so they build with the standard Void source build tooling and are reviewed through the repository's normal pull-request flow. The application template consumes the canonical release source distribution, checksum-verified, not a git archive.
- Packaging is thin: interpreter, GTK, PyGObject, and every Void-packaged Python dependency come from Void; only the two unpackaged dependencies are built from source under their own templates. There is no pip at build or install time, and the installer's venv arrangement does not apply to a system package. Package versions float on Void's versions within the project's declared ranges; the release smoke test is the drift net.
- The whisper.cpp binding is built with the Vulkan backend and portable CPU flags (shared libraries, no host-native tuning), with Vulkan exposed as a build option that source builders can turn off for a CPU-only rebuild. Its version is pinned to the version the project's lockfile and installer already pin, not to the newest upstream release. No CUDA package is produced: Void has no CUDA toolkit package. A user who installs a toolkit themselves may rebuild the binding locally with CUDA as a documented, unsupported escape hatch.
- The one known version gap — Void's audio binding package being older than the project's declared floor — is resolved deliberately and visibly, by carrying an updated local template for the personal repository first, never by disabling dependency verification to hide it. Relaxing the project's floor happens only if investigation shows the floor was raised without need.
- Speech models are never package payload. They download on first run against pinned checksums, failing closed on an unpinned model, exactly as today. Optional engines are not in the package and cannot be expressed as optional dependencies in xbps; the package's install message and the README state instead how to add them manually, note the input-group requirement for the evdev hotkey backend, note that models download on first run, and note that one injection helper tool is not yet packaged for Void so that fallback rung is simply absent there.
- The stale entries in the project's distro package inventory for Void (a renamed indicator library, an absent injection tool) are corrected as part of bringing the inventory in line with the templates, after verification against a real Void system.
- Void has no systemd; anything that would assume a user service uses the XDG autostart path instead.
### Release pipeline and upstream intake
- Releases stay tag-driven on the existing version tag form, with the tag required to match the version source of truth. A dedicated xbps release workflow runs on tag push inside the pinned Void glibc container, builds with the Void source tooling, and attaches the package, its signature, and the full signed repository data to the release. Every release carries the complete signed repository data because the repository URL serves the newest release's downloads.
- Signing uses one RSA 4096 key, generated once on a trusted machine, never committed, exposed to CI only as a secret. The workflow fails rather than publishing anything unsigned when the key is unavailable. The key fingerprint is published in the README next to the repository-registration instructions and Void's own third-party-repository caveat. Pushing the tag itself remains a human act requiring explicit approval each time, and no release asset is ever built or uploaded by hand.
- The post-release template version/checksum bump goes through the normal pull-request flow like every other change, never as a bot commit to the main branch.
- Upstream fixes are taken as sync-point hand cherry-picks: at each release-preparation cycle and on demand for critical fixes (crash, data loss, security), never as continuous tracking and never as wholesale merges. Intake freezes once a release branch opens, admitting only justified release-blockers. Each port is its own pull request through the standard test, lint, and type gates, referencing both the upstream and fork numbers. Conflicts resolve by keeping upstream logic and re-applying the fork's renames on top. Fixes and user-instruction-changing documentation qualify for porting; features are re-decided per release, and dependency or CI churn is ported only when a security advisory names it.
### Performance baseline
- Budgets are measured against a fixed three-metric protocol on the target machine with the default model from the ladder above: idle tray memory with the model unloaded (after the idle unload timeout) and loaded; wake latency from toggle to capture start; and transcription real-time factor on a fixed thirty-second sample per backend. Injection latency for the portal path versus direct typing is measured as part of the same pass.
- The protocol is already defined; the numbers are not yet taken, because taking them needs an installed build. Measuring them on the first installed build is an early implementation task, and it gates two things: calibration of the model-ladder memory thresholds, and any numeric performance budget the project later commits to. Until then, no performance number is claimed anywhere.
## Testing Decisions
- A good test here asserts external behaviour — what a user, a desktop, a package manager, or a later maintainer can observe — never internal structure. Tests name behaviour in the project's own vocabulary, run under the suite's existing conventions (per-file test modules, standard markers, the ten-second default timeout), and prefer the highest existing seam over any new one. Four seams cover this spec.
- Seam 1, the existing Python behaviour suite, is primary. It covers: the rename invariants (new directory, desktop, D-Bus, and binary identities; retained internal module name); the one-time import rules (import happens once, new configuration wins, no merge, no write-back, restore token carried); model selection (rung chosen by mocked free-memory readings, language-variant pairing, manual selections untouched); the conditional backend order (toolkit present versus absent); the one-time step-down retry on load failure; capture-source selection and its refusal to fall back silently across families; injection ordering, terminal paste-shortcut choice, modifier-release waiting, portal denial and session-cleanup behaviour; the engine-promise surface (guaranteed default, extras detected and selectable, configured engine preserved); the tray watcher probe accepting the watcher names hardware proves real; and the new D-Bus state signal following dictation state transitions. Prior art: the current suite already tests these areas behaviourally — portal cancellation semantics, PipeWire source resolution, engine availability, configuration loading, focused-window detection — and new tests extend those patterns rather than inventing new harnesses.
- Seam 2, the existing offline policy-test pattern, covers repository consistency that must never drift: the xbps templates (package name, dependency list reconciled against the project's declared dependency ranges, renamed install paths, Vulkan build option, pinned binding version); the release workflow's shape (version-tag gate, signing steps present, failure when the signing secret is absent, full repository data attached, template bump by pull request); a user-visible naming sweep proving no old-name strings remain on user surfaces outside the allowlist (internal module name, upstream URLs, historical changelog); and the existing checksum-pin and generated-file consistency checks, which keep passing unchanged. Prior art: the suite's current packaging and policy tests for the other distribution formats, which validate generated artifacts and workflow wiring offline without building anything.
- Seam 3, the container package smoke, covers the packaged artifact as users receive it. Using podman with the pinned Void glibc container: the templates pass the Void template linter, the packages build, the application installs into a clean Void environment, the renamed binary reports its version, core imports (desktop bindings, the whisper.cpp binding) load, and the signed repository data verifies against the fingerprint the README publishes. Prior art: the existing container gates that boot or install the other packaged formats in pinned distro containers and run the same version-plus-import smoke.
- Seam 4, a manual hardware acceptance checklist on the target machine class (Void, niri, Dank Material Shell, RTX 2050), covers what no container can: the tray renders under the shell and the watcher-name question is settled by observation; the niri keybind toggles dictation with acceptable latency and the documented double-trigger configuration is proven to misbehave only when misconfigured as warned; dictation types into Ghostty via direct typing with the clipboard untouched; hold-to-talk works through the input backend; the portal probe result on niri is recorded; models load on CUDA and Vulkan at the expected rungs; and the performance protocol numbers are taken and recorded on the relevant ticket. Where a GNOME or KDE machine is available, the checklist also covers the portal grant, token persistence across restarts, and deny-then-fallback behaviour. Checklist results are recorded as evidence on the implementation tickets, not asserted in CI.
## Out of Scope
- A Rust rebuild of the application, in whole or as a sidecar, and any GTK 4 migration.
- musl builds, non-x86_64 architectures, and submission of any package to upstream void-packages.
- Forking niri or Dank Material Shell, and depending on their internal or unreleased APIs — including compositor-driven press/release push-to-talk until a numbered niri release ships it.
- A Dank Material Shell bar plugin as a deliverable of this spec (the D-Bus state signal that unblocks it is in scope; the plugin is a later optional extra), and any web-UI rewrite of the desktop app.
- A CUDA build inside the xbps package, bundling speech models into any package, and packaging the one injection helper tool Void lacks (its absence simply removes that fallback rung on Void).
- Dropping, replacing, or promoting any optional engine, and per-application (rather than per-sink) system-audio capture.
- Changing upstream-facing artefacts: the internal Python module name, upstream URLs, and existing behaviour on non-Void distributions beyond what the rename itself touches.
## Further Notes
- Provenance: this spec synthesises the fourteen decisions of the completed wayfinder map [Voculinux port](https://git.bongbetic.com/xavierk/voculinux/issues/1), each recorded as a resolution comment on its ticket: [Rust rebuild viability](https://git.bongbetic.com/xavierk/voculinux/issues/2), [RTX 2050 CUDA model fit](https://git.bongbetic.com/xavierk/voculinux/issues/3), [Void xbps packaging route](https://git.bongbetic.com/xavierk/voculinux/issues/4), [niri and Dank integration APIs](https://git.bongbetic.com/xavierk/voculinux/issues/5), [Wayland terminal injection choice](https://git.bongbetic.com/xavierk/voculinux/issues/6), [Carbon and Bongbetic UI direction](https://git.bongbetic.com/xavierk/voculinux/issues/7), [Rename mechanics](https://git.bongbetic.com/xavierk/voculinux/issues/8), [Python performance baseline](https://git.bongbetic.com/xavierk/voculinux/issues/9), [Optional engines support promise](https://git.bongbetic.com/xavierk/voculinux/issues/10), [PipeWire capture details](https://git.bongbetic.com/xavierk/voculinux/issues/11), [Model catalog for 4 GB VRAM](https://git.bongbetic.com/xavierk/voculinux/issues/12), [Cherry-pick policy for upstream fixes](https://git.bongbetic.com/xavierk/voculinux/issues/13), [Signed xbps repository release pipeline](https://git.bongbetic.com/xavierk/voculinux/issues/14), and [Portal first-run prompt UX under niri](https://git.bongbetic.com/xavierk/voculinux/issues/15). Those decisions were made by agents under the user's explicit delegation during the wayfinding run, each grounded in inspected code or primary sources and labelled with that provenance on its ticket. This spec is the human review gate for them; the decisions most worth a human second look are the retained internal module name, whisper.cpp as the sole guaranteed engine, the Carbon-remapped palette choice, and the cherry-pick freeze.
- Where an earlier research ticket and a later decision ticket disagree, the later decision wins and this spec follows it: release tags keep the existing version-tag form rather than a name-prefixed form; templates live under the packaging tree with the voculinux name; only glibc x86_64 is published even though the template route could produce musl; the portal rung is described as a GNOME/KDE rung with niri starting at direct typing; and the one-time import also carries the portal restore token, a refinement the final ticket added to the rename decision. Two smaller items are derived rather than explicitly ticketed, and are stated here so they can be challenged: the backend-override environment name renames with the other user-visible names and gets no legacy alias, consistent with the D-Bus decision; and reusing the existing Bongbetic repository signing key satisfies the single-key rule, so users pin one fingerprint across repositories.
- Three claims in this spec rest on documentation and code reading rather than hardware observation, and the acceptance checklist exists to close them: the StatusNotifier watcher bus name the shell actually owns, the absence of any RemoteDesktop portal backend on niri, and relative model speed on the exact target GPU (the default rung was chosen on guaranteed memory fit, not a benchmark). The performance numbers and the model-ladder memory thresholds are likewise deferred to the first installed build, as the baseline decision recorded; until they are measured they are protocol, not results.
- One minor product behaviour is consciously left to implementation: the app's built-in update checker points at upstream-style releases, while packaged updates should arrive through the package manager. The documentation will say updates come through xbps; whether the checker is adjusted for packaged installs is decided during the packaging work if it proves to misdirect users.
- The Carbon direction was settled against a throwaway three-variant web prototype whose branch could not be pushed (the upstream remote rejected it); its findings are recorded in full on the UI direction ticket, and the prototype itself must never merge. The repository has no glossary or architecture-decision records yet; the domain terms this spec relies on — guaranteed versus best-effort engines, the model ladder and its rungs, the retained internal module name, Carbon token mapping as structure-not-palette — are defined in the tickets and summarised above, and remain candidates for a future glossary when one is started.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem Statement
A Void Linux user running a Wayland desktop under niri with Dank Material Shell has no packaged, offline voice dictation app that fits that stack. Vocalinux, the upstream Python GTK 3 tray dictation app, treats Void as experimental and untested, offers no xbps package, and makes several assumptions that do not hold on this machine: its CUDA-oriented model recommendation is unreachable on a Vulkan-capable Nvidia card, so an RTX 2050 with 4 GB of VRAM silently gets a small unquantized model instead of one sized for the card; its text-injection and hotkey behaviour under niri is undocumented; and everything user-visible carries the upstream name. The user wants dictation that lands at the cursor in a terminal under niri without clobbering the clipboard, a model default that actually uses their GPU, a signed xbps package they can install and update like any other Void package, and their own product name and branding on the app and the web site — without forking niri or Dank Material Shell, and without losing the ability to take upstream fixes by hand.
Solution
Voculinux is a hard fork of Vocalinux 0.18.5 for Void Linux (glibc, x86_64), Wayland-primary under niri with Dank Material Shell. It stays a Python GTK 3 tray app with whisper.cpp as the one guaranteed speech engine. Everything user-visible renames to voculinux — binaries, desktop identity, configuration and data directories, D-Bus identity, package, and web site — while the internal Python module name stays vocalinux so upstream fixes remain cheap to cherry-pick. A first run imports a legacy Vocalinux configuration once. Dictation under niri is activated from niri's own keybinds calling the app's existing toggle triggers, the tray speaks the standard StatusNotifier protocol so Dank Material Shell hosts it with no shell-specific code, and text reaches the cursor through a clipboard-free-first fallback ladder whose effective first rung on niri is direct typing. On a 4 GB Nvidia card the default model becomes a quantized large turbo model chosen by free VRAM at load time, stepping down automatically when memory is tight or a load fails. Distribution is a signed personal xbps repository, built and signed in CI from version tags, with the package depending on Void's own Python and GTK packages and speech models downloading on first run against pinned checksums. The web site is rebranded in the same effort, using Carbon's structural discipline remapped to the existing house palette.
User Stories
Implementation Decisions
Stack and scope posture
Rename and first-run import
vocalinuxas a retained internal name, documented as such. Upstream URLs and changelog history stay untouched for provenance. This split keeps hand cherry-picks from upstream nearly conflict-free, with conflicts concentrated at the renamed user-visible surfaces.Engine promise
Model selection on 4 GB-class hardware
Capture
Text injection and the portal
Activation, tray, and D-Bus state
Branding and the web site
Void packaging
Release pipeline and upstream intake
Performance baseline
Testing Decisions
Out of Scope
Further Notes