* feat(dictionary): combined custom dictionary with corrections Rebased onto latest main (conflict-resolution squash for PR #818). * fix(dictionary): stabilize the terms and corrections file contracts - corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over. - _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask. - build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment. * fix(settings): restore the selected dictionary pane after a search Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose. * fix(dictionary): close the remaining custom dictionary file-contract gaps - A --dictionary-file session override no longer imports a corrections file found beside the override into the standard config directory; the override scopes to terms only. - Terms reads validate the whole file before yielding: corrupt UTF-8 mid-file now yields no terms at all, matching the invalid status Settings reports instead of leaking a partial dictionary to recognition. - The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger file is invalid everywhere with a dedicated status instead of being read on every segment. * fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides * fix(dictionary): cap yielded terms and keep added terms visible on oversized files * fix(dictionary): keep added terms visible without dropping scanner-owned lines * fix(dictionary): keep oversized terms manageable and honor mid-search pane switches - terms list gains a Show all / Show first 500 toggle; adding a term beyond the cap auto-expands so the new row renders with a Remove button instead of being invisible until the file is hand-edited - a manual dictionary pane switch during an active search drops the saved pre-search pane so clearing the query no longer rolls back the user's newer selection - annotate the new settings tests and type model_info so the parakeet branch stops adding fresh mypy errors * fix(settings): do not auto-expand terms on add past display cap Rebuilding up to thousands of term rows on the UI thread stalls the dialog; the overflow button already shows the true count, so the added term stays one click away. * test(dictionary): stub segment_callbacks on bare manager for history dispatch * fix(dictionary): stream oversized terms files when building the prompt build_initial_prompt decoded the whole 1 MiB read window before every transcription segment even though it only needs the first max_words terms. _iter_terms_for_prompt now reads oversized files line by line and stops at the prompt limit, keeping the same contract: complete leading lines only, invalid UTF-8 inside the usable window yields nothing. * fix(history): only the ended session's worker may extend its snippet A late segment could extend the newest history entry regardless of which session produced it, so a worker finishing after the next dictation session leaked its text into that session's entry. Track the worker that owned the ended session and require late segments to arrive on the same thread before merging; anything else forms its own snippet. * fix(speech): transcribe queued segments the reload worker never drained The recognition worker can exit during stop_recognition's bounded audio-thread join, so the released key's final buffer lands in the queue after the worker is gone and was silently dropped when the reload teardown replaced the queue. Drain whatever remains before swapping it — except on capture failure or an explicit cancel, which still discard the session's leftovers. * fix(settings): build term rows in idle slices and pin hidden additions Two problems shared one cause. Rebuilding up to 4096 rows synchronously stalled the dialog, and a term appended past the 500-row cap reported success while staying invisible. Rows now build in bounded GLib idle slices guarded by a refresh token, and a saved term outside the visible window is pinned to the top of the capped list until it disappears or becomes visible on its own. Adding under "show all" scrolls to the new term instead. * test(engine): annotate new test method return types * fix(ci): match a candidate row that leads the latest track block snapcraft status normally prints candidate as a continuation line under the latest track (channel in column 1). If candidate ever leads the track block it rides the track line instead (channel in column 3), and the resolver missed it and refused to promote. Match both layouts in the resolve and the pre-release re-verify. * fix(dictionary): bound prompt reads and scroll to landed terms - _iter_terms_for_prompt now reads each line via readline() capped by the remaining byte window, so an overlong line can never pull more than MAX_TERMS_FILE_BYTES into memory - the prompt path keeps decoding every complete line inside the usable window after the term limit is collected, so invalid UTF-8 anywhere in the window still yields no terms and matches the reader's invalid status - under "show all" Settings scrolls to the index where an added term landed (prepended or appended), verified against the row's label, instead of always scrolling to the end * fix(history,reload): keep late-segment ownership and drain stamped tuples - Track session_workers_seen so a worker from an earlier session can never merge into a newer snippet while a session is open - Unpack (segment, language) queue tuples in _transcribe_queued_segments; passing the tuple as audio raised TypeError and lost final speech - Refuse 'delete that' when a manual pad edit blurred segment boundaries - Translate term-row coordinates into the scrolled card's space so the scroll actually reaches rows below the fold - Type _on_dictionary_pane_changed's varargs * style: drop unneeded nonlocal for mutated worker set --------- Co-authored-by: Graham <graham@thegeeky.ninja>
126 lines
6.8 KiB
Markdown
126 lines
6.8 KiB
Markdown
# Custom Dictionary Support Design
|
|
|
|
## Scope
|
|
|
|
Custom dictionary support has two separate jobs:
|
|
|
|
1. **Custom terms** bias local Whisper, whisper.cpp, and Faster Whisper recognition
|
|
toward names and jargon.
|
|
2. **Transcript corrections** deterministically replace a known misheard phrase
|
|
after transcription.
|
|
|
|
They appear together on the **Custom Dictionary** Settings page, but are not
|
|
interchangeable. A correction replacement does not implicitly become a prompt
|
|
term. That coupling would make recognition bias depend on correction order and
|
|
would make the external terms contract surprising.
|
|
|
|
## File contracts
|
|
|
|
Both files live in VocaLinux's XDG-aware configuration directory:
|
|
`$XDG_CONFIG_HOME/vocalinux` (or `~/.config/vocalinux` when unset).
|
|
|
|
| File | Contract | Consumer |
|
|
| --- | --- | --- |
|
|
| `dictionary.txt` | UTF-8 (BOM accepted), one term per line. Blank lines and lines beginning with `#` are ignored. Terms are de-duplicated case-insensitively while retaining the first spelling. | VocaLinux and an external accessibility scanner. |
|
|
| `custom-dictionary-corrections.json` | UTF-8 JSON object: `{"version": 1, "corrections": [{"heard": "super base", "replacement": "Supabase"}]}`. | VocaLinux only. |
|
|
|
|
The terms file remains deliberately simple and scanner-friendly. Corrections
|
|
need two unambiguous fields, a version, and exact replacement casing, so JSON is
|
|
used rather than overloading the line file with an ambiguous delimiter grammar.
|
|
The UI writes either file by atomic replacement; a failed UI save preserves the
|
|
old file and reports an error.
|
|
|
|
The application reads both files for each completed transcript. An externally
|
|
edited file therefore takes effect on the next dictation segment without an app
|
|
restart. Missing, unreadable, malformed, or unsupported correction files are
|
|
ignored safely; dictation continues without their entries.
|
|
|
|
The terms file may contain any number of lines, but VocaLinux limits a single
|
|
prompt to the first 200 valid terms and 2,000 characters. This bounds decoding
|
|
work while leaving the scanner's complete line-file contract intact. The Custom
|
|
Dictionary page keeps both an add/remove editor and a validated file chooser
|
|
for the terms file.
|
|
|
|
## Processing and engine behavior
|
|
|
|
For every completed audio segment, VocaLinux applies this order:
|
|
|
|
1. The selected engine transcribes audio.
|
|
2. Live phrase corrections run against the raw transcript.
|
|
3. Voice-command interpretation runs on that corrected text.
|
|
4. Remaining text and actions are dispatched.
|
|
|
|
Corrections run before commands so a correction can prevent a command-like
|
|
misrecognition from being acted on. This also means users must not create a
|
|
replacement that deliberately creates a voice command unless that is intended.
|
|
|
|
Corrections match literal phrases case-insensitively, only where the adjacent
|
|
characters are not Unicode word characters (or common combining marks). Longer
|
|
phrases win over shorter overlaps; equal-length entries retain JSON order.
|
|
Replacement text is inserted exactly as written. Inputs are normalized to NFC
|
|
for matching, so composed/decomposed accents are handled consistently.
|
|
|
|
| Engine | Custom terms | Corrections |
|
|
| --- | --- | --- |
|
|
| OpenAI Whisper | `initial_prompt` | Yes |
|
|
| whisper.cpp | Advanced initial prompt followed by custom terms; explicit empty prompt clears reused native state | Yes |
|
|
| Faster Whisper | `initial_prompt` | Yes |
|
|
| VOSK | No prompt-bias API | Yes |
|
|
| Parakeet | No prompt-bias API | Yes |
|
|
| Remote API | No prompt is added to current request formats | Yes |
|
|
|
|
`--dictionary-file PATH` preserves the #767 session-only behavior: `PATH` is
|
|
used as the custom terms file, it enables terms for that session, and Settings
|
|
does not modify the saved terms file or enablement. Corrections remain loaded
|
|
from the standard JSON file. The previous experimental #768
|
|
`text_injection.custom_dictionary` config list is read only as a fallback until
|
|
the JSON corrections file exists; it is never silently deleted or migrated.
|
|
|
|
The #767 persisted configuration keys are retained unchanged:
|
|
`dictionary.enabled`, `dictionary.file_path` (default
|
|
`~/.config/vocalinux/dictionary.txt`), and `dictionary.max_words`. Existing
|
|
Peony settings therefore continue to select the scanner's `dictionary.txt`
|
|
file. New configured paths are accepted only when they can be expanded and are
|
|
either absent (so the UI can create them) or readable regular files; a failed
|
|
save leaves the prior setting in place. Invalid configured and `--dictionary-file`
|
|
paths are safely ignored and shown as unavailable rather than raising.
|
|
|
|
## Alternatives rejected
|
|
|
|
- **One mixed line file:** cannot express arbitrary phrases and replacements
|
|
without reserving/escaping delimiters, which weakens the scanner contract.
|
|
- **Corrections in `config.json`:** makes a scanner compete with application
|
|
settings writes and offers no durable external-file schema.
|
|
- **Post-command correction:** allows command-like transcription errors to be
|
|
acted on before they can be fixed.
|
|
- **Automatically adding correction replacements to prompts:** rejected because
|
|
replacements are not automatically included in the recognition prompt;
|
|
corrections work post-transcription on every engine.
|
|
|
|
## Human test checklist
|
|
|
|
1. Add `VocaLinux`, `PyGObject`, and a non-ASCII term in **Custom terms**;
|
|
confirm `dictionary.txt` is UTF-8, one line per term, and an external
|
|
edit changes the next Whisper, whisper.cpp, and Faster Whisper dictation segment.
|
|
2. Add `super base` → `Supabase`; test lowercase, uppercase, punctuation, an
|
|
overlapping short correction, `C++`, and non-ASCII text. Confirm replacement
|
|
casing is exact and embedded text (for example `supersize`) is not replaced.
|
|
3. With voice commands enabled, add a correction that changes a command-like
|
|
misrecognition into ordinary text. Confirm it is injected rather than acted
|
|
on. Also verify an intentionally command-like replacement is avoided.
|
|
4. Edit each file externally while VocaLinux is running. Test an invalid JSON
|
|
correction file and invalid UTF-8 terms file; confirm dictation continues and
|
|
the Settings status/save feedback is clear.
|
|
5. Test the UI with keyboard only: Add buttons via Enter, empty validation,
|
|
update/remove feedback, narrow window, long entries, and high-contrast theme.
|
|
6. Test all engines: Whisper, whisper.cpp, and Faster Whisper should use terms and
|
|
corrections; VOSK, Parakeet, and remote API should use corrections only, with
|
|
the VOSK terms warning.
|
|
7. Start with `--dictionary-file /path/to/terms.txt`; confirm the session uses
|
|
that file, Settings disables terms editing, saved settings remain unchanged,
|
|
and corrections still apply.
|
|
8. Start with an unresolved path such as `--dictionary-file ~missing-user/terms.txt`
|
|
and configure an unreadable path through the file chooser. Confirm Settings
|
|
reports the path safely, dictation continues without terms, and the prior
|
|
saved `dictionary.file_path` remains intact.
|