From d89e93ae02771fff4272f0ec036eb4662fd47948 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Fri, 9 Jan 2026 11:43:54 +1300 Subject: [PATCH 01/15] Update documentation for clarity and consistency - Rearranged links in _sidebar.md for better organization. - Updated index.html title and meta description for improved SEO. - Corrected minor grammatical errors and improved phrasing in README.md and other documentation files. - Enhanced clarity in Getting_Started_Overview.md and Troubleshooting_Install.md. - Adjusted formatting and descriptions in various sections for better readability. --- .../Getting_Started_Overview.md | 2 +- docs/Installation/Troubleshooting_Install.md | 6 ++-- docs/Installation/Windows/Kaldi.md | 4 +-- .../Windows/Windows_Speech_Recognition.md | 10 +++--- docs/README.md | 6 ++-- .../Intro_Into_Rules_and_Grammars.md | 28 +++++++-------- docs/User_Dir/Caster_User_Dir.md | 2 +- docs/_sidebar.md | 14 ++++---- docs/index.html | 34 ++++++++++--------- docs/meta/Caster_FAQ.md | 18 +++++----- 10 files changed, 63 insertions(+), 61 deletions(-) diff --git a/docs/Getting_Started/Getting_Started_Overview.md b/docs/Getting_Started/Getting_Started_Overview.md index 3fd2c3dd0..6d7850b0d 100644 --- a/docs/Getting_Started/Getting_Started_Overview.md +++ b/docs/Getting_Started/Getting_Started_Overview.md @@ -7,4 +7,4 @@ This is a place where your journey to control your computer by voice begins. The 3. Customizing existing and creating your own sets of voice commands 4. Explanation of Caster settings -So to get started by choosing a speech recognition engine. +Start by choosing a speech recognition engine. diff --git a/docs/Installation/Troubleshooting_Install.md b/docs/Installation/Troubleshooting_Install.md index 7dbec3a43..b9aba5d51 100644 --- a/docs/Installation/Troubleshooting_Install.md +++ b/docs/Installation/Troubleshooting_Install.md @@ -17,15 +17,15 @@ Please raise an issue if you find Legion still doesn't work on Windows 10 after - If commands work in some applications but not others that are supported by Caster, verify that the program is not running as administrator with elevated privileges. Dragonfly grammars cannot interact with programs that have administrator/elevated privileges. To fix: - Run the .bat file as administrator. **Note** this means the entirety of Caster and Dragonfly will run as administrator which poses a significant security risk in general. Use this method with caution. - - [Proof of Concept](https://github.com/dictation-toolbox/dragonfly/issues/11) work around but the project needs an active developer with C#. This allows Caster and Dragonfly to only elevate specific functionality that is necessary such as Key/Text actions and application context details. - - Advanced [Workaround](https://groups.google.com/g/dragonflyspeech/c/2VrJKBI2mSo/m/R4zl6u2mBwAJ) - Editing natlink.exe with hex editor and re-signing with self signed certificate - **Use at your own risk!** Instructions note disadvantages. + - [Proof of Concept](https://github.com/dictation-toolbox/dragonfly/issues/11) workaround but the project needs an active developer with C#. This allows Caster and Dragonfly to only elevate specific functionality that is necessary such as Key/Text actions and application context details. + - Advanced [Workaround](https://groups.google.com/g/dragonflyspeech/c/2VrJKBI2mSo/m/R4zl6u2mBwAJ) - Editing natlink.exe with a hex editor and re-signing with a self-signed certificate - **Use at your own risk!** Instructions note disadvantages. - Fix `TypeError: command must be a non-empty string, not ['C:\\Python27\\Scripts\\pip.exe', 'search', 'castervoice'` - Update Dragonfly `python -m pip install dragonfly2 --upgrade` in command prompt - To fix `ImportError: No module named win32con` - Package win32con is out of date or not installed. Try `python -m pip install pywin32`. Alternatively if the error persists use the [Windows installer](https://sourceforge.net/projects/pywin32/files/pywin32/Build%20221/pywin32-221.win32-py2.7.exe/download) -- To fix `lost sys.stder` use `pywin32` for `system wide` features, such as registering COM objects or implementing Windows Services. Depending on Python version, run the following command from an elevated CMD: +- To fix `lost sys.stderr`, use `pywin32` for `system-wide` features, such as registering COM objects or implementing Windows Services. Depending on Python version, run the following command from an elevated CMD: - `python C:\Python27\Scripts\pywin32_postinstall.py -install` - To fix `ImportError: cannot import name RuleWrap` - You likely either have the wrong version of Dragonfly installed or don't have it installed at all. RuleWrap is a Dragonfly import. Try `pip uninstall dragonfly` (it's okay if it doesn't find the package) then `pip install dragonfly2`. diff --git a/docs/Installation/Windows/Kaldi.md b/docs/Installation/Windows/Kaldi.md index b5b48a77a..f1051e73a 100644 --- a/docs/Installation/Windows/Kaldi.md +++ b/docs/Installation/Windows/Kaldi.md @@ -24,12 +24,12 @@ Caster currently supports Kaldi on Microsoft Windows 7 through Windows 10. Consi 1. Download your preferred Kaldi model at [kaldi-active-grammar/releases](https://github.com/daanzu/kaldi-active-grammar/releases) 2. Extract `kaldi_model_< Model Type >.zip` to `%USERPROFILE%\Documents\Caster` -## 4. Launch for Kaldi for Classic Install. +## 4. Launch Caster (Kaldi) for Classic Install 1. Go to `%USERPROFILE%\Documents\Caster` 2. Double-click on `Run_Caster_Kaldi.bat` -**Note:** Kaldi is flexible engine which can be configured via engine parameters to customize your experience. +**Note:** Kaldi is a flexible engine which can be configured via engine parameters to customize your experience. - You can modify the `Run_Caster_Kaldi.bat` file for `python -m dragonfly load _*.py --engine kaldi --no-recobs-messages --engine-options "model_dir=kaldi_model, vad_padding_end_ms=300"` - List of kaldi [engine parameters](https://dragonfly2.readthedocs.io/en/latest/kaldi_engine.html#engine-configuration). Scroll down for parameter explanations. diff --git a/docs/Installation/Windows/Windows_Speech_Recognition.md b/docs/Installation/Windows/Windows_Speech_Recognition.md index 3cfa418c4..58487b018 100644 --- a/docs/Installation/Windows/Windows_Speech_Recognition.md +++ b/docs/Installation/Windows/Windows_Speech_Recognition.md @@ -4,7 +4,7 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows ## 1. Python -- **First** Download and install [Python 3](https://www.python.org/downloads/release/python-388/) listed as `Windows x86-64 MSI installer`.` +- **First** Download and install [Python 3](https://www.python.org/downloads/release/python-388/) listed as `Windows x86-64 MSI installer`. - Make sure to select `Add python to path`. This can be done manually by searching for "edit environment variables for your account" and adding your Python folder to the list of Path values. ## 2. Caster @@ -16,19 +16,19 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows - The Legion MouseGrid requires [Microsoft Visual C++ Redistributable Packages for Visual Studio 2015, 2017 and 2019 (x86).](https://support.microsoft.com/en-nz/help/2977003/the-latest-supported-visual-c-downloads) Note: Should not be needed if Windows 10 is up-to-date. 5. Click `Install_Caster_DNS-WSR.bat` to install prerequisite Caster dependencies. -## 4. Launch Caster for Classic Install +## 3. Launch Caster for Classic Install 1. Go to `%USERPROFILE%\Documents\Caster` 2. Start Caster by double clicking on `Start_Caster_WSR.py`. - 3. To test open Window's Notepad, try saying `arch brov char delta` producing `abcd` text. Set up complete! + 3. To test, open Windows Notepad and try saying `arch brov char delta` producing `abcd` text. Setup complete! ## Update Caster 1. Backup `%USERPROFILE%\Documents\Caster` 2. Delete `%USERPROFILE%\Documents\Caster` - 3. Repeat Steps `1. - 4.` within the Caster install section + 3. Repeat Steps `1. - 3.` within the Caster install section ------ @@ -38,7 +38,7 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows > com_error: (-2147352567, 'Exception occurred.', (0, None, None, None, 0, -2004287480), None)` -- Receive the `-win32com.gen_py' has no attribute 'CLSIDToClassMap` COM error when Caster starts. +- Receive the `'win32com.gen_py' has no attribute 'CLSIDToClassMap'` COM error when Caster starts. > WARNING:engine:Exception while initializing sapi5 engine: module 'win32com.gen_py.C866CA3A-32F7-11D2-9602-00C04F8EE628x0x5x4' has no attribute 'CLSIDToClassMap' > ERROR:command:Exception while initializing sapi5 engine: module 'win32com.gen_py.C866CA3A-32F7-11D2-9602-00C04F8EE628x0x5x4' has no attribute 'CLSIDToClassMap diff --git a/docs/README.md b/docs/README.md index 028e3a355..ec66e51d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,5 @@ # Caster -[![Caster Lint/Unit Tests](https://github.com/dictation-toolbox/Caster/actions/workflows/pythonpackage.yml/badge.svg)](https://github.com/dictation-toolbox/Caster/actions/workflows/pythonpackage.yml)![GitHub top language](https://img.shields.io/github/languages/top/dictation-toolbox/Caster)[![https://github.com/dictation-toolbox/Caster/blob/master/LICENSE](https://img.shields.io/badge/license-GNU-informational)](https://matrix.to/#/!KOGBGqVOBKJjKLgFUY:matrix.org?via=matrix.org) +[![Caster Lint/Unit Tests](https://github.com/dictation-toolbox/Caster/actions/workflows/pythonpackage.yml/badge.svg)](https://github.com/dictation-toolbox/Caster/actions/workflows/pythonpackage.yml) ![GitHub top language](https://img.shields.io/github/languages/top/dictation-toolbox/Caster) [![License](https://img.shields.io/badge/license-GNU-informational)](https://github.com/dictation-toolbox/Caster/blob/master/LICENSE) [Caster](https://github.com/dictation-toolbox/Caster) gives you the power to control your computer by voice. Take control of your applications, games, mouse and keyboard to augment your workflow for every day activities or as an accessibility tool to develop applications entirely by voice built upon the [Dragonfly](https://github.com/dictation-toolbox/dragonfly) framework. @@ -24,7 +24,7 @@ - `find` will execute a `ctrl-f` keystroke, bringing up the find and replace prompt, - `open file` will execute a `ctrl-o` keystroke, - `edit next ` - `ctrl-d` n times, selecting the next n instances of the currently selected word. - - [Program Languages](https://dictation-toolbox.github.io/Caster/#/Caster_Commands/CCR_languages_Quick_Reference) - Language specific coding - These modules are activated and deactivated with the `enable/disable ` voice command. For example after saying `enable python`, the following commands become available for dictation: + - [Programming Languages](https://dictation-toolbox.github.io/Caster/#/Caster_Commands/CCR_languages_Quick_Reference) - Language specific coding - These modules are activated and deactivated with the `enable/disable ` voice command. For example after saying `enable python`, the following commands become available for dictation: - `for loop` which will insert `for i in range(0, ):` - `print to console` - `print()` - `open file` - `open('filename', 'r') as f:` @@ -67,7 +67,7 @@ - Python, Java, Bash, C++, C#, Rust, Go, HTML, CSS, JavaScript, SQL, Dart, Latex , Matlab, R, Prolog, VHDL, and Haxe -- [Editing and Navigation](https://github.com/dictation-toolbox/Caster/blob/master/CasterQuickReference.pdf) +- [Editing and Navigation](https://github.com/dictation-toolbox/Caster/blob/master/docs/CasterQuickReference.pdf) - Five additional [mouse navigation modes](https://dictation-toolbox.github.io/Caster/#/Caster_Commands/Mouse): curse, sudoku, douglas, legion, and rainbow - [Demo](https://www.youtube.com/watch?v=UISjQBMmQ-I&feature=youtu.be) - [Text navigation commands](https://dictation-toolbox.github.io/Caster/#/Caster_Commands/Text_Manipulation) - [Demo](https://www.youtube.com/watch?v=xj8IzNlfM70), Text formatting commands diff --git a/docs/Rule_Construction/Intro_Into_Rules_and_Grammars.md b/docs/Rule_Construction/Intro_Into_Rules_and_Grammars.md index 93a9ef0cd..c1e8678e3 100644 --- a/docs/Rule_Construction/Intro_Into_Rules_and_Grammars.md +++ b/docs/Rule_Construction/Intro_Into_Rules_and_Grammars.md @@ -1,6 +1,6 @@ -# Intro Grammars and Rules +# Intro to Grammars and Rules -This section is adapted from the Dragonfly documentation on rules and grammars ([Sources](https://dragonfly2.readthedocs.io/en/latest/object_model.html)). These terms are the building blocks for you to understand how to customize or build your own grammars and rules. Most of [Dragonfly Documentation](https://dragonfly2.readthedocs.io/en/latest/index.html) directly applies to Caster. Starting off the Dragonfly documentation is not needed to understand this documentation. The Caster documentation shows how to create your own rules and grammars by example. However there are differences which are mentioned throughout the documentation. +This section is adapted from the Dragonfly documentation on rules and grammars ([Sources](https://dragonfly2.readthedocs.io/en/latest/object_model.html)). These terms are the building blocks for understanding how to customize or build your own grammars and rules. Most of the [Dragonfly documentation](https://dragonfly2.readthedocs.io/en/latest/index.html) directly applies to Caster. Starting with the Dragonfly documentation is not required to understand this documentation. The Caster documentation shows how to create your own rules and grammars by example. However there are differences which are mentioned throughout the documentation. ## Rules @@ -10,25 +10,25 @@ This section is adapted from the Dragonfly documentation on rules and grammars ( In general a `grammar` is a collection of rules that are related to each other. It manages the rules, loading and unloading them, activating and deactivating them, and it takes care of all communications with the speech recognition engine. When a recognition occurs, the associated grammar receives the recognition event and dispatches it to the appropriate rule. -Common Examples of different context: +Common examples of different contexts: -- **Global** the rules are available everywhere all the time on the computer. Collectively they are a global grammars. -- **Application** specific rules are only available to a specific application or set of applications. For instance the Firefox application might have a rule that allows you to switch between tabs by voice. Through a Firefox extension you may have a separate rule that controls the extension. Collectively both rules are the Firefox grammar because they share the same context. +- **Global**: the rules are available everywhere all the time on the computer. Collectively they are a global grammar. +- **Application**: specific rules are only available to a specific application or set of applications. For instance the Firefox application might have a rule that allows you to switch between tabs by voice. Through a Firefox extension you may have a separate rule that controls the extension. Collectively both rules are the Firefox grammar because they share the same context. -This seems very complex, by and large you'll be working with `rules` as Caster creates and manages the grammars for you. +This seems very complex; by and large you'll be working with `rules` as Caster creates and manages the grammars for you. -## **Rule Construction Overview** +## Rule Construction Overview -Rule construction documentation will walk youu through the following: +Rule construction documentation will walk you through the following: -- `Taxonomy of a Rule` gives a pictorial representation of the anatomy of a rule. The terms defined there are the basics for what you need to know to follow along with the rest of the documentation. +- `Taxonomy of a Rule` gives a pictorial representation of the anatomy of a rule. The terms defined there are the basics for what you need to know to follow along with the rest of the documentation. -- `Your First Rule` - Guides you just experimenting with your own Global rule without delving deep into details. +- `Your First Rule` - Guides you through experimenting with your own global rule without delving deep into details. -- `Basic Rules` - Expands in detail of what you are introduced to durinng `Your First Rule` . +- `Basic Rules` - Expands on what you are introduced to during `Your First Rule`. - **Note** It is highly recommended that you skip `Advanced Rules` to `Loading Rules` until you're comfortably going your own basic rules. + **Note** It is highly recommended that you skip `Advanced Rules` and `Loading Rules` until you're comfortable writing your own basic rules. -- `Advanced Rules` - Goes in-depth with castor specific features such as CCR +- `Advanced Rules` - Goes in-depth with Caster-specific features such as CCR -- `Loading Rules` - Which shows you how to load and define when and where rules are active such as `global` or `application` contexts. \ No newline at end of file +- `Loading Rules` - Which shows you how to load and define when and where rules are active such as `global` or `application` contexts. diff --git a/docs/User_Dir/Caster_User_Dir.md b/docs/User_Dir/Caster_User_Dir.md index fe5817ef1..55f670d23 100644 --- a/docs/User_Dir/Caster_User_Dir.md +++ b/docs/User_Dir/Caster_User_Dir.md @@ -12,7 +12,7 @@ The default locations are: Users can set the location of their Caster user directory using the environment variable `CASTER_USER_DIR`. For help on setting environment variables on your system, search for "setting environment variables <_your OS_>". -### Layout Description +### Layout Description - `data` - Caster stores the files that are not intended to be edited by the user in the `data` directory. Most files here are for self-modifying rules, such as [Alias](https://dictation-toolbox.github.io/Caster/#/Caster_Commands/Alias), and start with `sm_`. - `caster_user_content\hooks` - For user-made [hooks](https://dictation-toolbox.github.io/Caster/#/Caster_Settings/hooks) (Empty by default). diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 8dee8c3c1..f84b05601 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -25,8 +25,8 @@ * [Alias](/Caster_Commands/Alias.md) * [Bring Me](/Caster_Commands/Bringme.md) * Programming By Voice - * [Speaking Examples](/Getting_Started/How_to_Speak_Code/How_to_Speak_Code_Intro.md) - * [How to Speak Code](/Getting_Started/How_to_Speak_Code/Speaking/Examples.md) + * [How to Speak Code](/Getting_Started/How_to_Speak_Code/How_to_Speak_Code_Intro.md) + * [Speaking Examples](/Getting_Started/How_to_Speak_Code/Speaking/Examples.md) * [Caster User Directory](/User_Dir/Caster_User_Dir.md) * Rule Construction * [Intro into Rules and Grammars](/Rule_Construction/Intro_Into_Rules_and_Grammars.md) @@ -41,8 +41,8 @@ * [Loading Rules](/Rule_Construction/Loading_Rules.md) * [Migrating From Dragonfly or Previous Caster Versions](/Rule_Construction/Migrating_From_Dragonfly-Caster.md) * Customize Caster - * [Editing Starter Rules]("/Customize_Caster/Customizing_Starter_Rules.md") - * [Creating Hooks and Events]("/Customize_Caster/Creating_Hooks_and_Events.md") + * [Editing Starter Rules](/Customize_Caster/Customizing_Starter_Rules.md) + * [Creating Hooks and Events](/Customize_Caster/Creating_Hooks_and_Events.md) * Explanation of Settings * [Rules](/Caster_Settings/rules.md) * [Caster Settings](/Caster_Settings/settings.md) @@ -52,8 +52,8 @@ * Third-party Integrations * [Aenea](/Third-party_Integrations/Aenea.md) * [Sikuli](/Third-party_Integrations/Sikuli.md) -* [Contributing](Contributing.md) - * [Code of Conduct](CODE_OF_CONDUCT.md) +* [Contributing](/Contributing.md) + * [Code of Conduct](/CODE_OF_CONDUCT.md) * Meta * [Undocumented Features](/meta/Undocumented_Features.md) * [Grammar Documentation Template](/meta/GRAMMAR_DOCUMENTATION_TEMPLATE.md) @@ -61,4 +61,4 @@ * [Sample File](/meta/Dragonfly_Rules/Sample_File.md) * Archive * [RuleFilters](/meta/Archive/RuleFilters.md) - * [Windows_Speech_Recognition](/meta/Archive/Windows_Speech_Recognition.md) \ No newline at end of file + * [Windows_Speech_Recognition](/meta/Archive/Windows_Speech_Recognition.md) diff --git a/docs/index.html b/docs/index.html index a1ef6f55f..bafb1a6d1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -2,9 +2,9 @@ - Document + Caster Docs - + @@ -19,22 +19,24 @@ auto2top: true, search: 'auto', alias: { - '/.*/_sidebar.md': '/_sidebar.md', // See #301 - }, - } + '/.*/_sidebar.md': '/_sidebar.md', // See #301 + }, + }; - - - + - - - - - - - - + + + + + + + + + + + + diff --git a/docs/meta/Caster_FAQ.md b/docs/meta/Caster_FAQ.md index 3c389a4ed..0bfbd0a46 100644 --- a/docs/meta/Caster_FAQ.md +++ b/docs/meta/Caster_FAQ.md @@ -1,6 +1,6 @@ -# **Caster Frequently Asked Questions - FAQ** +# Caster Frequently Asked Questions (FAQ) -Welcome to the Caster project! This FAQ guide this to help introduce the Caster project and the community. +Welcome to the Caster project! This FAQ is meant to help introduce the project and the community. 1. **What type of individuals are in the Caster community?** @@ -10,15 +10,15 @@ Welcome to the Caster project! This FAQ guide this to help introduce the Caster 2. **Is Caster only meant to be used by developers who code only by voice?** - Both developers and everyday users can benefit from a simple experience to control their computer. Caster was designed for voice programmers who develop software. However programming is a barrier for most people. We want to simplify experience for the everyday users, yet maintain the power and flexibility necessary for developers underneath the hood. + Both developers and everyday users can benefit from a simple experience to control their computer. Caster was designed for voice programmers who develop software. However programming is a barrier for most people. We want to simplify the experience for everyday users, yet maintain the power and flexibility necessary for developers underneath the hood. - Caster can be used without programming experience and customized on a basic level through simplified transformers. Which allow you to redefine command names without programming experience. We acknowledge there is a lot of work to be done to make Caster truly accessible for those without a technical background, but that vision is a work in progress. + Caster can be used without programming experience and customized on a basic level through simplified transformers, which allow you to redefine command names without programming experience. We acknowledge there is a lot of work to be done to make Caster truly accessible for those without a technical background, but that vision is a work in progress. 3. **Maybe I do not need certain features. How flexible is Caster?** - The voice commands are purposely structured to allow you to pick and choose how you want to use the project. This allows for a broad userbase with diverse interests and abelites. A configuration file allows you to block categories of commands called `rules` giving you control over your experience. The sky is the limit. Consider the following use cases: + The voice commands are purposely structured to allow you to pick and choose how you want to use the project. This allows for a broad user base with diverse interests and abilities. A configuration file allows you to block categories of commands called `rules`, giving you control over your experience. The sky is the limit. Consider the following use cases: - - I am developer or person who does not have barriers using computer inputs like keyboard and mouse. I only want to control applications by voice to augment their workflow. + - I am a developer or person who does not have barriers using computer inputs like the keyboard and mouse. I only want to control applications by voice to augment my workflow. You can disable all the rules that are related to voice programming, advanced mouse and keyboard control. @@ -26,7 +26,7 @@ Welcome to the Caster project! This FAQ guide this to help introduce the Caster You could disable all the starter rules and create your own with Caster as a framework. -4. **Why does the Caster use obscure or strange words for command names?** +4. **Why does Caster use obscure or strange words for command names?** Typically speech recognition engines are great with sentences not short commands. Basically the general vocabulary is too ambiguous for speech recognition engines to discern between words that are phonetically close and how they are pronounced. Think of how similar "end", "and", "n" and "m" sound, and if that's all you heard and did not know the context of it being in a sentence. Words that are phonetically distinct from each other increase speech recognition accuracy. @@ -37,7 +37,7 @@ Welcome to the Caster project! This FAQ guide this to help introduce the Caster - Customizing commands will lead to higher accuracy - Commands that are created by the end-user are more likely to be remembered - You can customize commands by copying starter rules, utilizing simplified transformers, or creating your own from scratch. See the read the `Caster Rules` in the documentation. + You can customize commands by copying starter rules, utilizing simplified transformers, or creating your own from scratch. See the `Caster Rules` section in the documentation. 6. **Does Caster support multiple languages?** @@ -45,7 +45,7 @@ Welcome to the Caster project! This FAQ guide this to help introduce the Caster 7. **Does Dragonfly come with its own commands?** - Dragonfly does not come with built-in commands, and you would have to to create their own commands. + Dragonfly does not come with built-in commands, and you would have to create your own commands. 8. **What's the difference between Caster and Dragonfly?** From 0df185565b23000a90129f20d46f81da849ca5b4 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Feb 2026 08:01:24 +1300 Subject: [PATCH 02/15] Separate WSR/Kaldi install dependency paths --- CHANGELOG.md | 9 ++ Install_Caster_DNS-WSR.bat | 13 +++ Install_Caster_Kaldi.bat | 13 +++ Install_Caster_WSR.bat | 110 ++++++++++++++++++ Run_Caster_WSR.bat | 29 ++++- castervoice/lib/ctrl/dependencies.py | 12 +- castervoice/lib/qt.py | 13 ++- .../Choose_Recognition_Engine.md | 3 + .../Windows/Dragon_NaturallySpeaking.md | 4 +- .../Windows/Windows_Speech_Recognition.md | 23 +++- requirements.txt | 20 ++-- tests/lib/ctrl/test_dependencies.py | 67 +++++++++++ 12 files changed, 293 insertions(+), 23 deletions(-) create mode 100644 Install_Caster_WSR.bat create mode 100644 tests/lib/ctrl/test_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4572f0f0..83583a370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- Added `Install_Caster_WSR.bat` for WSR installs using `uv` and Python 3.12+. +- Updated WSR Windows installation docs to use `Install_Caster_WSR.bat`. +- Updated Windows Qt dependency handling for Python 3.12 compatibility (`PySide6` path for supported architectures, no hard `PySide2` requirement on Windows). +- Updated WSR scripts to launch with the same interpreter used during install (via `castervoice\bin\data\wsr_python_path.txt`). +- Updated WSR Qt install gating to use Python bitness detection instead of host architecture. +- Left `Install_Caster_DNS-WSR.bat` in place for DNS/Natlink installs. + ## [0.6.14](https://github.com/dictation-toolbox/Caster/tree/0.6.14) ("2019-12-01") [Full Changelog](https://github.com/dictation-toolbox/Caster/compare/0.5.11...0.6.14) diff --git a/Install_Caster_DNS-WSR.bat b/Install_Caster_DNS-WSR.bat index 114aa3752..1fed96c76 100644 --- a/Install_Caster_DNS-WSR.bat +++ b/Install_Caster_DNS-WSR.bat @@ -25,4 +25,17 @@ py -%python_version% -m pip install --upgrade pip echo Installing Caster Dependencies for DNS/WSR py -%python_version% -m pip install -r "%currentpath%requirements.txt" +echo Installing optional Qt bindings for HUD/settings UI features (best effort) +py -%python_version% -m pip install --only-binary=:all: "PySide6>=6.6" +if errorlevel 1 ( + echo NOTICE: PySide6 wheel unavailable for this interpreter; trying PySide2. + py -%python_version% -m pip install --only-binary=:all: "PySide2>=5.14" + if errorlevel 1 ( + echo NOTICE: No compatible Qt wheel found for this interpreter. + echo NOTICE: Core DNS/WSR grammar functionality can still run. + echo NOTICE: HUD/settings-window features requiring Qt may be unavailable. + cmd /c exit /b 0 + ) +) + pause 1 diff --git a/Install_Caster_Kaldi.bat b/Install_Caster_Kaldi.bat index 0baf9a74a..3f5292a4d 100644 --- a/Install_Caster_Kaldi.bat +++ b/Install_Caster_Kaldi.bat @@ -8,6 +8,19 @@ echo Installing Caster Dependencies py -m pip install -r "%currentpath%requirements.txt" py -m pip install dragonfly2[kaldi] +echo Installing optional Qt bindings for HUD/settings UI features (best effort) +py -m pip install --only-binary=:all: "PySide6>=6.6" +if errorlevel 1 ( + echo NOTICE: PySide6 wheel unavailable for this interpreter; trying PySide2. + py -m pip install --only-binary=:all: "PySide2>=5.14" + if errorlevel 1 ( + echo NOTICE: No compatible Qt wheel found for this interpreter. + echo NOTICE: Core Kaldi grammar functionality can still run. + echo NOTICE: HUD/settings-window features requiring Qt may be unavailable. + cmd /c exit /b 0 + ) +) + echo Remember: Manually install kaldi a model. echo See Caster kaldi install instructions on ReadTheDocs. diff --git a/Install_Caster_WSR.bat b/Install_Caster_WSR.bat new file mode 100644 index 000000000..b84c5e5d7 --- /dev/null +++ b/Install_Caster_WSR.bat @@ -0,0 +1,110 @@ +@echo off + +SetLocal DisableDelayedExpansion +set "currentpath=%~dp0" +set "wsr_python_metadata=%currentpath%castervoice\bin\data\wsr_python_path.txt" +set "wsr_python_request=>=3.12" + +echo Installation path: %currentpath% +echo Installing Caster dependencies for WSR using uv and Python 3.12+. + +where uv >nul 2>nul +if errorlevel 1 ( + echo ERROR: uv is required but was not found in PATH. + echo Install uv first: https://docs.astral.sh/uv/getting-started/installation/ + exit /b 1 +) + +echo Validating Python 3.12+ availability through uv... +uv run --python "%wsr_python_request%" -- python --version +if errorlevel 1 ( + echo ERROR: Unable to use Python 3.12+ with uv. + echo Install Python 3.12+ or run: uv python install 3.12+ + exit /b 2 +) + +for /f "usebackq delims=" %%i in (`uv run --python "%wsr_python_request%" -- python -c "import sys; print(sys.executable)"`) do set "wsr_python=%%i" +if not defined wsr_python ( + echo ERROR: Failed to resolve the Python interpreter path used for WSR. + exit /b 6 +) +if exist "%wsr_python%" goto :wsr_python_ready +echo ERROR: Resolved WSR Python interpreter does not exist: %wsr_python% +exit /b 6 + +:wsr_python_ready + +echo Upgrading pip for WSR interpreter... +uv pip install --system --python "%wsr_python%" --upgrade pip +if errorlevel 1 ( + echo ERROR: Failed while upgrading pip for WSR interpreter. + exit /b 3 +) + +echo Installing Caster dependencies for WSR... +uv pip install --system --python "%wsr_python%" -r "%currentpath%requirements.txt" +if errorlevel 1 ( + echo ERROR: Failed while installing dependencies from requirements.txt. + exit /b 4 +) + +for /f "usebackq delims=" %%i in (`uv run --python "%wsr_python%" -- python -c "import struct; print(8*struct.calcsize('P'))"`) do set "py_bits=%%i" +if not defined py_bits ( + echo ERROR: Failed to determine Python bitness for Qt dependency installation. + exit /b 7 +) + +if "%py_bits%"=="64" goto :install_qt +if "%py_bits%"=="32" goto :skip_qt_32 +goto :skip_qt_other + +:install_qt +echo Installing Qt bindings for WSR UI features... +uv pip install --system --python "%wsr_python%" --only-binary=:all: "PySide6>=6.6" +if errorlevel 1 goto :qt_install_failed +goto :after_qt + +:qt_install_failed +echo WARNING: Failed while installing PySide6 for Qt-based UI features. +echo WARNING: Continuing install without Qt-based UI features. +cmd /c exit /b 0 +goto :after_qt + +:skip_qt_32 +echo NOTICE: Skipping Qt dependency for detected 32-bit Python. +echo NOTICE: HUD and settings-window features that require Qt may be unavailable. +goto :after_qt + +:skip_qt_other +echo NOTICE: Skipping Qt dependency because Python bitness "%py_bits%" is not supported. +echo NOTICE: HUD and settings-window features that require Qt may be unavailable. + +:after_qt + +for %%d in ("%wsr_python_metadata%") do set "wsr_python_metadata_dir=%%~dpd" +if exist "%wsr_python_metadata_dir%" goto :wsr_metadata_dir_ready +mkdir "%wsr_python_metadata_dir%" +if errorlevel 1 goto :wsr_metadata_dir_create_failed + +:wsr_metadata_dir_ready +"%wsr_python%" -c "import os, pathlib; pathlib.Path(os.environ['wsr_python_metadata']).write_text(os.environ['wsr_python'] + '\n', encoding='utf-8')" +if errorlevel 1 goto :wsr_metadata_write_failed +goto :install_complete + +:wsr_metadata_dir_create_failed +echo ERROR: Failed to create metadata directory: %wsr_python_metadata_dir% +exit /b 6 + +:wsr_metadata_write_failed +echo ERROR: Failed to write WSR interpreter metadata: %wsr_python_metadata% +exit /b 6 + +:install_complete + +echo. +echo WSR dependency installation completed successfully. +echo WSR runtime interpreter: %wsr_python% +echo WSR interpreter metadata: %wsr_python_metadata% +echo NOTE: Qt-based UI features require a supported 64-bit Python architecture. +echo Next step: run Run_Caster_WSR.bat +pause 1 diff --git a/Run_Caster_WSR.bat b/Run_Caster_WSR.bat index 02280b510..9a6e125f2 100644 --- a/Run_Caster_WSR.bat +++ b/Run_Caster_WSR.bat @@ -1,9 +1,36 @@ @echo off echo Running WRS from Dragonfly CLI. -set currentpath=%~dp0 +SetLocal DisableDelayedExpansion +set "currentpath=%~dp0" +set "wsr_python_metadata=%currentpath%castervoice\bin\data\wsr_python_path.txt" +set "runtime_python=" TITLE Caster: Status Window +if exist "%wsr_python_metadata%" ( + for /f "usebackq delims=" %%i in ("%wsr_python_metadata%") do if not defined runtime_python set "runtime_python=%%i" +) + +if not defined runtime_python goto :fallback_py +if not exist "%runtime_python%" goto :invalid_runtime_python + +echo Using WSR interpreter: %runtime_python% +"%runtime_python%" -m dragonfly load --engine sapi5inproc _*.py --no-recobs-messages +goto :after_run + +:invalid_runtime_python +echo WARNING: Stored WSR interpreter path is invalid: %runtime_python% + +:fallback_py +where py >nul 2>nul +if errorlevel 1 ( + echo ERROR: No valid WSR interpreter metadata and no 'py' launcher found. + echo Run Install_Caster_WSR.bat first to configure the runtime interpreter. + goto :after_run +) + +echo NOTICE: Falling back to default py launcher interpreter. py -m dragonfly load --engine sapi5inproc _*.py --no-recobs-messages +:after_run pause 1 diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index e56bb380c..d745a89c9 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -37,15 +37,19 @@ def dep_missing(): with open(requirements) as f: requirements = f.read().splitlines() for dep in requirements: - dep = dep.split(">=", 1)[0] + dep = dep.strip() + if not dep or dep.startswith("#"): + continue try: - pkg_resources.require("{}".format(dep)) + pkg_resources.require(dep) except VersionConflict: pass except DistributionNotFound: - missing_list.append('{0}'.format(dep)) + # Keep markers for evaluation, but exclude them in pip install guidance. + missing_list.append(dep.split(";", 1)[0].strip()) if missing_list: - pippackages = (' '.join(map(str, missing_list))) + # Quote each requirement to avoid shell redirection parsing in version specifiers (for example >=). + pippackages = " ".join(['"{0}"'.format(dep) for dep in missing_list]) printer.out("\nCaster: dependencys are missing. Use 'python -m pip install {0}'".format(pippackages)) time.sleep(10) diff --git a/castervoice/lib/qt.py b/castervoice/lib/qt.py index f4e735647..e449e8159 100644 --- a/castervoice/lib/qt.py +++ b/castervoice/lib/qt.py @@ -8,9 +8,16 @@ try: from PySide2 import QtCore, QtGui, QtWidgets # type: ignore QT_API = "PySide2" -except ImportError: # pragma: no cover - from PySide6 import QtCore, QtGui, QtWidgets # type: ignore - QT_API = "PySide6" +except ImportError as pyside2_err: # pragma: no cover + try: + from PySide6 import QtCore, QtGui, QtWidgets # type: ignore + QT_API = "PySide6" + except ImportError as pyside6_err: # pragma: no cover + raise ImportError( + "Unable to import Qt bindings (PySide2/PySide6). " + "Qt is required for HUD/settings/HMC UI features. " + "On Windows x64, install with: uv pip install --system --python 3.12 \"PySide6>=6.6\"" + ) from pyside6_err def qt_attr(root, *paths): diff --git a/docs/Getting_Started/Choose_Recognition_Engine.md b/docs/Getting_Started/Choose_Recognition_Engine.md index 1e060af23..3e83e8df5 100644 --- a/docs/Getting_Started/Choose_Recognition_Engine.md +++ b/docs/Getting_Started/Choose_Recognition_Engine.md @@ -71,6 +71,9 @@ Disadvantages - Free - Simple to set up with the least dependencies +- WSR setup uses `Install_Caster_WSR.bat` from the project root. See [Windows Speech Recognition install](../Installation/Windows/Windows_Speech_Recognition.md). +- `Run_Caster_WSR.bat` uses interpreter metadata from installation so runtime and installed packages stay aligned. +- For Qt-based UI features (HUD/settings), use a supported Windows x64 Python environment. - Preinstalled on all supported Windows OS diff --git a/docs/Installation/Windows/Dragon_NaturallySpeaking.md b/docs/Installation/Windows/Dragon_NaturallySpeaking.md index c71eb22a6..ddc2faf57 100644 --- a/docs/Installation/Windows/Dragon_NaturallySpeaking.md +++ b/docs/Installation/Windows/Dragon_NaturallySpeaking.md @@ -21,6 +21,8 @@ After installing Dragon, you can configure the DNS settings based on your prefer 1. Download Caster from the [master branch](https://github.com/dictation-toolbox/Caster/archive/master.zip). 2. Extract the files. You can put it anywhere but it is common to use `%USERPROFILE%\Documents\Caster-master`. The `Caster-master` could be renamed to `Caster`. 3. Install dependencies and set up Natlink by running `Caster-master/Install_Caster_DNS-WSR.bat`. + - If you are using Windows Speech Recognition (WSR) instead of DNS/Natlink, use `Install_Caster_WSR.bat` and follow the [WSR install guide](./Windows_Speech_Recognition.md). + - DNS/Natlink installation flow is unchanged in this release. 4. *Optional Step* for Caster's`Legion` MouseGrid - Legion Feature available on Windows 10 and above - The Legion MouseGrid requires [Microsoft Visual C++ Redistributable Packages for Visual Studio 2015, 2017 and 2019 (x86).](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist) @@ -69,4 +71,4 @@ Scrips starting with an underscore and ending in .py `_*.py` will be imported in #### Where are natlink configuration files located? -- The natlinkconfig_gui or natlinkconfig_cli creates configuration files in`%UserProfile%\.natlink` as `natlink.ini`. \ No newline at end of file +- The natlinkconfig_gui or natlinkconfig_cli creates configuration files in`%UserProfile%\.natlink` as `natlink.ini`. diff --git a/docs/Installation/Windows/Windows_Speech_Recognition.md b/docs/Installation/Windows/Windows_Speech_Recognition.md index 58487b018..a57557110 100644 --- a/docs/Installation/Windows/Windows_Speech_Recognition.md +++ b/docs/Installation/Windows/Windows_Speech_Recognition.md @@ -2,10 +2,12 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows 7 through Windows 10. -## 1. Python +## 1. Prerequisites -- **First** Download and install [Python 3](https://www.python.org/downloads/release/python-388/) listed as `Windows x86-64 MSI installer`. +- Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and ensure `uv` is available on `PATH`. +- **First** Download and install [Python 3.12+](https://www.python.org/downloads/) listed as `Windows x86-64 MSI installer`. - Make sure to select `Add python to path`. This can be done manually by searching for "edit environment variables for your account" and adding your Python folder to the list of Path values. + - The WSR installer is validated for 64-bit Python on Windows. ## 2. Caster @@ -14,13 +16,14 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows 3. Copy the contents of `Caster-master` folder, you can put it anywhere but it is common to use `%USERPROFILE%\Documents\Caster`. 4. *Optional Step* for Caster's`Legion` MouseGrid - Legion Feature available on Windows 8 and above - The Legion MouseGrid requires [Microsoft Visual C++ Redistributable Packages for Visual Studio 2015, 2017 and 2019 (x86).](https://support.microsoft.com/en-nz/help/2977003/the-latest-supported-visual-c-downloads) Note: Should not be needed if Windows 10 is up-to-date. - 5. Click `Install_Caster_DNS-WSR.bat` to install prerequisite Caster dependencies. + 5. Click `Install_Caster_WSR.bat` to install prerequisite Caster dependencies for WSR. + - The installer records the exact Python interpreter it used so `Run_Caster_WSR.bat` launches with the same runtime environment. ## 3. Launch Caster for Classic Install 1. Go to `%USERPROFILE%\Documents\Caster` - 2. Start Caster by double clicking on `Start_Caster_WSR.py`. + 2. Start Caster by double clicking on `Run_Caster_WSR.bat`. 3. To test, open Windows Notepad and try saying `arch brov char delta` producing `abcd` text. Setup complete! @@ -34,6 +37,18 @@ Caster currently supports Windows Speech Recognition (WSR) on Microsoft Windows ### Troubleshooting Windows Speech Recognition +- Receive an error that `uv` is not recognized. + + > fix: install uv from https://docs.astral.sh/uv/getting-started/installation/ and restart your shell. + +- Receive a message that Qt dependencies were skipped. + + > This happens on unsupported architectures (for example some 32-bit Windows Python environments). Core WSR grammar functionality can still run, but HUD/settings-window features requiring Qt are unavailable. + +- Receive an error about missing WSR interpreter metadata or missing `py` launcher in `Run_Caster_WSR.bat`. + + > fix: rerun `Install_Caster_WSR.bat` to re-create `castervoice\bin\data\wsr_python_path.txt`. + - Receive the `-2147352567` COM error when Caster starts. This is most likely related to the microphone being utilized by another program. See [issue #821](https://github.com/dictation-toolbox/Caster/issues/821) and [#68](https://github.com/dictation-toolbox/Caster/issues/68). This can be mitigated by closing the program that's utilizing the microphone. > com_error: (-2147352567, 'Exception occurred.', (0, None, None, None, 0, -2004287480), None)` diff --git a/requirements.txt b/requirements.txt index 7dc131fb5..7edc1b490 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -dragonfly2>=0.34.0 -pillow==9.5.0 -tomlkit>=0.11.8 -future>=0.18.2 -mock>=3.0.5 -appdirs>=1.4.3 -scandir>=1.10.0 -pyvda==0.0.8 -PySide2>=5.14 -six +dragonfly2>=0.34.0 +pillow==9.5.0 +tomlkit>=0.11.8 +future>=0.18.2 +mock>=3.0.5 +appdirs>=1.4.3 +scandir>=1.10.0 +pyvda==0.0.8 +PySide2>=5.14;platform_system!="Windows" +six diff --git a/tests/lib/ctrl/test_dependencies.py b/tests/lib/ctrl/test_dependencies.py new file mode 100644 index 000000000..c25d4e542 --- /dev/null +++ b/tests/lib/ctrl/test_dependencies.py @@ -0,0 +1,67 @@ +import unittest +from unittest.mock import mock_open, patch + +from castervoice.lib.ctrl import dependencies + + +class TestDependencies(unittest.TestCase): + + def test_dep_missing_uses_full_requirement_spec(self): + requirements = 'PySide2>=5.14;platform_system!="Windows"\n' + with patch("builtins.open", mock_open(read_data=requirements)): + with patch("castervoice.lib.ctrl.dependencies.pkg_resources.require") as require_mock: + with patch("castervoice.lib.ctrl.dependencies.printer.out") as out_mock: + with patch("castervoice.lib.ctrl.dependencies.time.sleep") as sleep_mock: + dependencies.dep_missing() + + require_mock.assert_called_once_with('PySide2>=5.14;platform_system!="Windows"') + out_mock.assert_not_called() + sleep_mock.assert_not_called() + + def test_dep_missing_reports_missing_dep_without_marker_in_hint(self): + requirements = 'missing_dep>=1.0; platform_system=="Windows"\n' + with patch("builtins.open", mock_open(read_data=requirements)): + with patch("castervoice.lib.ctrl.dependencies.pkg_resources.require") as require_mock: + with patch("castervoice.lib.ctrl.dependencies.printer.out") as out_mock: + with patch("castervoice.lib.ctrl.dependencies.time.sleep") as sleep_mock: + require_mock.side_effect = dependencies.DistributionNotFound("missing_dep", []) + dependencies.dep_missing() + + out_mock.assert_called_once() + warning_message = out_mock.call_args[0][0] + self.assertIn('python -m pip install "missing_dep>=1.0"', warning_message) + self.assertNotIn("platform_system", warning_message) + sleep_mock.assert_called_once_with(10) + + def test_dep_missing_quotes_multiple_missing_requirements_in_hint(self): + requirements = ( + 'missing_dep>=1.0\n' + 'other_dep==2.0; platform_system=="Windows"\n' + ) + with patch("builtins.open", mock_open(read_data=requirements)): + with patch("castervoice.lib.ctrl.dependencies.pkg_resources.require") as require_mock: + with patch("castervoice.lib.ctrl.dependencies.printer.out") as out_mock: + with patch("castervoice.lib.ctrl.dependencies.time.sleep") as sleep_mock: + require_mock.side_effect = [ + dependencies.DistributionNotFound("missing_dep", []), + dependencies.DistributionNotFound("other_dep", []), + ] + dependencies.dep_missing() + + out_mock.assert_called_once() + warning_message = out_mock.call_args[0][0] + self.assertIn('python -m pip install "missing_dep>=1.0" "other_dep==2.0"', warning_message) + self.assertNotIn("platform_system", warning_message) + sleep_mock.assert_called_once_with(10) + + def test_dep_missing_skips_blank_and_comment_lines(self): + requirements = '\n# optional dependency\nsix\n' + with patch("builtins.open", mock_open(read_data=requirements)): + with patch("castervoice.lib.ctrl.dependencies.pkg_resources.require") as require_mock: + with patch("castervoice.lib.ctrl.dependencies.printer.out") as out_mock: + with patch("castervoice.lib.ctrl.dependencies.time.sleep") as sleep_mock: + dependencies.dep_missing() + + require_mock.assert_called_once_with("six") + out_mock.assert_not_called() + sleep_mock.assert_not_called() From 97d98bc37164da68e16af5dfa3960d8b271f1e9b Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Feb 2026 11:44:47 +1300 Subject: [PATCH 03/15] Fix pylint import-error for pkg_resources --- castervoice/lib/ctrl/dependencies.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index d745a89c9..73e507280 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -3,8 +3,12 @@ @author: synkarius ''' -import os, sys, time, pkg_resources -from pkg_resources import VersionConflict, DistributionNotFound +import os +import sys +import time + +import pkg_resources # pylint: disable=import-error +from pkg_resources import DistributionNotFound, VersionConflict # pylint: disable=import-error from castervoice.lib import printer DARWIN = sys.platform == "darwin" From 3f915d7f64ea3168646aa4c7e267ca033a5be837 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Feb 2026 11:49:27 +1300 Subject: [PATCH 04/15] Make dependency checks work without setuptools --- castervoice/lib/ctrl/dependencies.py | 58 +++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index 73e507280..3f55c6f2b 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -7,8 +7,62 @@ import sys import time -import pkg_resources # pylint: disable=import-error -from pkg_resources import DistributionNotFound, VersionConflict # pylint: disable=import-error +try: + import pkg_resources # pylint: disable=import-error + DistributionNotFound = pkg_resources.DistributionNotFound + VersionConflict = pkg_resources.VersionConflict +except ModuleNotFoundError: + from importlib import metadata + + try: + from packaging.requirements import Requirement + from packaging.version import InvalidVersion, Version + except ModuleNotFoundError: # pragma: no cover - fallback when packaging isn't installed directly + from pip._vendor.packaging.requirements import Requirement # pylint: disable=import-error + from pip._vendor.packaging.version import InvalidVersion, Version # pylint: disable=import-error + + class DistributionNotFound(Exception): + """Raised when a required distribution is not installed.""" + + class VersionConflict(Exception): + """Raised when an installed distribution does not satisfy the requested version.""" + + def __init__(self, dist, req): + self.dist = dist + self.req = req + super().__init__("{0} does not satisfy {1}".format(dist, req)) + + def _installed_version(distribution_name): + for candidate in ( + distribution_name, + distribution_name.replace("_", "-"), + distribution_name.replace("-", "_"), + ): + try: + return metadata.version(candidate) + except metadata.PackageNotFoundError: + continue + raise DistributionNotFound(distribution_name) + + def _require_fallback(requirement_spec): + requirement = Requirement(requirement_spec) + if requirement.marker and not requirement.marker.evaluate(): + return + installed_version = _installed_version(requirement.name) + if requirement.specifier: + try: + parsed_version = Version(installed_version) + except InvalidVersion: + parsed_version = installed_version + if parsed_version not in requirement.specifier: + raise VersionConflict(installed_version, requirement_spec) + + class _PkgResourcesShim: + @staticmethod + def require(requirement_spec): + _require_fallback(requirement_spec) + + pkg_resources = _PkgResourcesShim() from castervoice.lib import printer DARWIN = sys.platform == "darwin" From 69e7978c0315c735f762cc4a19144a3c90388d8d Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Feb 2026 12:04:50 +1300 Subject: [PATCH 05/15] Handle incomplete pkg_resources module in dependency checks --- castervoice/lib/ctrl/dependencies.py | 12 ++++++++++-- tests/lib/ctrl/test_dependencies.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index 3f55c6f2b..9557c797b 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -8,10 +8,18 @@ import time try: - import pkg_resources # pylint: disable=import-error + import pkg_resources as _pkg_resources # pylint: disable=import-error +except ModuleNotFoundError: + _pkg_resources = None + +if _pkg_resources is not None and all( + hasattr(_pkg_resources, attr) + for attr in ("require", "DistributionNotFound", "VersionConflict") +): + pkg_resources = _pkg_resources DistributionNotFound = pkg_resources.DistributionNotFound VersionConflict = pkg_resources.VersionConflict -except ModuleNotFoundError: +else: from importlib import metadata try: diff --git a/tests/lib/ctrl/test_dependencies.py b/tests/lib/ctrl/test_dependencies.py index c25d4e542..2bf51f920 100644 --- a/tests/lib/ctrl/test_dependencies.py +++ b/tests/lib/ctrl/test_dependencies.py @@ -1,3 +1,6 @@ +import importlib +import sys +import types import unittest from unittest.mock import mock_open, patch @@ -65,3 +68,15 @@ def test_dep_missing_skips_blank_and_comment_lines(self): require_mock.assert_called_once_with("six") out_mock.assert_not_called() sleep_mock.assert_not_called() + + def test_import_works_with_incomplete_pkg_resources_module(self): + fake_pkg_resources = types.SimpleNamespace(require=lambda _requirement: None) + original_module = dependencies + try: + with patch.dict(sys.modules, {"pkg_resources": fake_pkg_resources}): + reloaded = importlib.reload(original_module) + self.assertTrue(hasattr(reloaded, "DistributionNotFound")) + self.assertTrue(hasattr(reloaded, "VersionConflict")) + self.assertTrue(callable(reloaded.pkg_resources.require)) + finally: + importlib.reload(original_module) From 8e2335c718d4d18d202ecc75c7522325b804cfb8 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Feb 2026 12:08:57 +1300 Subject: [PATCH 06/15] Handle invalid installed versions in fallback checker --- castervoice/lib/ctrl/dependencies.py | 2 +- tests/lib/ctrl/test_dependencies.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index 9557c797b..cb1443ba8 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -61,7 +61,7 @@ def _require_fallback(requirement_spec): try: parsed_version = Version(installed_version) except InvalidVersion: - parsed_version = installed_version + raise VersionConflict(installed_version, requirement_spec) if parsed_version not in requirement.specifier: raise VersionConflict(installed_version, requirement_spec) diff --git a/tests/lib/ctrl/test_dependencies.py b/tests/lib/ctrl/test_dependencies.py index 2bf51f920..8df61942a 100644 --- a/tests/lib/ctrl/test_dependencies.py +++ b/tests/lib/ctrl/test_dependencies.py @@ -80,3 +80,15 @@ def test_import_works_with_incomplete_pkg_resources_module(self): self.assertTrue(callable(reloaded.pkg_resources.require)) finally: importlib.reload(original_module) + + def test_fallback_raises_version_conflict_for_invalid_installed_version(self): + fake_pkg_resources = types.SimpleNamespace(require=lambda _requirement: None) + original_module = dependencies + try: + with patch.dict(sys.modules, {"pkg_resources": fake_pkg_resources}): + reloaded = importlib.reload(original_module) + with patch.object(reloaded, "_installed_version", return_value="not_a_pep440_version"): + with self.assertRaises(reloaded.VersionConflict): + reloaded._require_fallback("example_pkg>=1.0") + finally: + importlib.reload(original_module) From 20fc7055029828f8e8c0f95f0dd39ed23642bb6a Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Fri, 20 Mar 2026 10:46:53 +1300 Subject: [PATCH 07/15] Handle extras in dependency fallback --- castervoice/lib/ctrl/dependencies.py | 38 +++++++++++++++++++++--- tests/lib/ctrl/test_dependencies.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index cb1443ba8..26ecf175b 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -23,10 +23,14 @@ from importlib import metadata try: + from packaging.markers import default_environment from packaging.requirements import Requirement + from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version except ModuleNotFoundError: # pragma: no cover - fallback when packaging isn't installed directly + from pip._vendor.packaging.markers import default_environment # pylint: disable=import-error from pip._vendor.packaging.requirements import Requirement # pylint: disable=import-error + from pip._vendor.packaging.utils import canonicalize_name # pylint: disable=import-error from pip._vendor.packaging.version import InvalidVersion, Version # pylint: disable=import-error class DistributionNotFound(Exception): @@ -40,22 +44,37 @@ def __init__(self, dist, req): self.req = req super().__init__("{0} does not satisfy {1}".format(dist, req)) - def _installed_version(distribution_name): + def _installed_distribution(distribution_name): for candidate in ( distribution_name, distribution_name.replace("_", "-"), distribution_name.replace("-", "_"), ): try: - return metadata.version(candidate) + return metadata.distribution(candidate) except metadata.PackageNotFoundError: continue raise DistributionNotFound(distribution_name) - def _require_fallback(requirement_spec): + def _installed_version(distribution_name): + return _installed_distribution(distribution_name).version + + def _require_fallback(requirement_spec, marker_environment=None, visited=None): requirement = Requirement(requirement_spec) - if requirement.marker and not requirement.marker.evaluate(): + marker_environment = dict(marker_environment or default_environment()) + if requirement.marker and not requirement.marker.evaluate(marker_environment): return + visited = visited if visited is not None else set() + visited_key = ( + canonicalize_name(requirement.name), + str(requirement.specifier), + tuple(sorted(requirement.extras)), + str(requirement.marker) if requirement.marker else None, + marker_environment.get("extra"), + ) + if visited_key in visited: + return + visited.add(visited_key) installed_version = _installed_version(requirement.name) if requirement.specifier: try: @@ -64,6 +83,17 @@ def _require_fallback(requirement_spec): raise VersionConflict(installed_version, requirement_spec) if parsed_version not in requirement.specifier: raise VersionConflict(installed_version, requirement_spec) + if not requirement.extras: + return + distribution = _installed_distribution(requirement.name) + for extra in requirement.extras: + extra_environment = dict(marker_environment) + extra_environment["extra"] = extra + for child_spec in distribution.requires or []: + child_requirement = Requirement(child_spec) + if child_requirement.marker and not child_requirement.marker.evaluate(extra_environment): + continue + _require_fallback(child_spec, extra_environment, visited) class _PkgResourcesShim: @staticmethod diff --git a/tests/lib/ctrl/test_dependencies.py b/tests/lib/ctrl/test_dependencies.py index 8df61942a..96b38fa76 100644 --- a/tests/lib/ctrl/test_dependencies.py +++ b/tests/lib/ctrl/test_dependencies.py @@ -92,3 +92,46 @@ def test_fallback_raises_version_conflict_for_invalid_installed_version(self): reloaded._require_fallback("example_pkg>=1.0") finally: importlib.reload(original_module) + + def test_fallback_checks_requested_extra_dependencies(self): + fake_pkg_resources = types.SimpleNamespace(require=lambda _requirement: None) + original_module = dependencies + try: + with patch.dict(sys.modules, {"pkg_resources": fake_pkg_resources}): + reloaded = importlib.reload(original_module) + + def fake_distribution(name): + if name == "dragonfly2": + return types.SimpleNamespace( + version="0.34.0", + requires=['kaldi-active-grammar; extra == "kaldi"'], + ) + raise reloaded.metadata.PackageNotFoundError + + with patch.object(reloaded.metadata, "distribution", side_effect=fake_distribution): + with self.assertRaises(reloaded.DistributionNotFound): + reloaded._require_fallback("dragonfly2[kaldi]>=0.34.0") + finally: + importlib.reload(original_module) + + def test_fallback_accepts_installed_requested_extra_dependencies(self): + fake_pkg_resources = types.SimpleNamespace(require=lambda _requirement: None) + original_module = dependencies + try: + with patch.dict(sys.modules, {"pkg_resources": fake_pkg_resources}): + reloaded = importlib.reload(original_module) + + def fake_distribution(name): + if name == "dragonfly2": + return types.SimpleNamespace( + version="0.34.0", + requires=['kaldi-active-grammar; extra == "kaldi"'], + ) + if name == "kaldi-active-grammar": + return types.SimpleNamespace(version="1.0", requires=[]) + raise reloaded.metadata.PackageNotFoundError + + with patch.object(reloaded.metadata, "distribution", side_effect=fake_distribution): + reloaded._require_fallback("dragonfly2[kaldi]>=0.34.0") + finally: + importlib.reload(original_module) From d7804b1e04710bc81931c5072d632a6971c0ef71 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 10:11:56 +1300 Subject: [PATCH 08/15] Clarify Qt feature messaging in Kaldi installer --- Install_Caster_Kaldi.bat | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Install_Caster_Kaldi.bat b/Install_Caster_Kaldi.bat index 9c4f9f875..836f9dc6f 100644 --- a/Install_Caster_Kaldi.bat +++ b/Install_Caster_Kaldi.bat @@ -259,25 +259,25 @@ if "%py_bits%"=="32" goto :skip_qt_32 goto :skip_qt_other :install_qt -echo Installing Qt bindings for Kaldi UI features... +echo Installing Qt bindings for Caster HUD/settings/HMC features... uv pip install --python "%runtime_python%" --only-binary=:all: "PySide6>=6.6" if errorlevel 1 goto :qt_install_failed goto :after_qt :qt_install_failed -echo WARNING: Failed while installing PySide6 for Qt-based UI features. -echo WARNING: Continuing install without Qt-based UI features. +echo WARNING: Failed while installing PySide6 for Caster HUD/settings/HMC features. +echo WARNING: Continuing install without those Qt-based UI features. cmd /c exit /b 0 goto :after_qt :skip_qt_32 echo NOTICE: Skipping Qt dependency for detected 32-bit Python. -echo NOTICE: HUD and settings-window features that require Qt may be unavailable. +echo NOTICE: HUD, settings-window, and HMC features that require Qt may be unavailable. goto :after_qt :skip_qt_other echo NOTICE: Skipping Qt dependency because Python bitness "%py_bits%" is not supported. -echo NOTICE: HUD and settings-window features that require Qt may be unavailable. +echo NOTICE: HUD, settings-window, and HMC features that require Qt may be unavailable. :after_qt @@ -326,7 +326,7 @@ if "%kaldi_model_status%"=="installed" echo Kaldi model directory: %repo_root%\k if "%kaldi_model_status%"=="skipped" echo Kaldi model download skipped. Run Install_Caster_Kaldi.bat later if you want the guided model download. if "%kaldi_model_status%"=="failed" echo Kaldi model install failed. Download a model later or rerun Install_Caster_Kaldi.bat. if "%kaldi_model_status%"=="helper-missing" echo Kaldi model helper is unavailable. Download a model manually from the upstream releases page. -if not "%qt_arch_supported%"=="1" echo NOTE: Qt-based UI features require a supported 64-bit Python architecture. +if not "%qt_arch_supported%"=="1" echo NOTE: Caster HUD/settings/HMC features require a supported 64-bit Python architecture. if not "%kaldi_model_status%"=="installed" echo See Caster Kaldi install instructions on ReadTheDocs. echo Next step: run Run_Caster_Kaldi.bat pause 1 From bce78d7d10465dbfa29567b0521da7ffa95fbb80 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 10:19:13 +1300 Subject: [PATCH 09/15] Show Kaldi model download progress --- castervoice/lib/kaldi_model.py | 68 +++++++++++++++++++++++++++++++--- tests/lib/test_kaldi_model.py | 44 +++++++++++++++++++++- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/castervoice/lib/kaldi_model.py b/castervoice/lib/kaldi_model.py index 110569f8d..e3174a659 100644 --- a/castervoice/lib/kaldi_model.py +++ b/castervoice/lib/kaldi_model.py @@ -15,6 +15,8 @@ MODEL_DIR_NAME = "kaldi_model" MODEL_METADATA_NAME = ".caster-model.json" USER_LEXICON_NAME = "user_lexicon.txt" +DOWNLOAD_CHUNK_SIZE = 1024 * 1024 +PROGRESS_BAR_WIDTH = 30 TIER_LABELS = { "medium": "Balanced (Recommended)", @@ -27,6 +29,54 @@ class ModelResolutionError(RuntimeError): pass +def format_byte_count(byte_count): + units = ("B", "KB", "MB", "GB", "TB") + size = float(byte_count) + for unit in units: + if unit == "B": + if size < 1024: + return "{0:.0f} {1}".format(size, unit) + else: + if size < 1024 or unit == units[-1]: + return "{0:.1f} {1}".format(size, unit) + size /= 1024.0 + return "{0:.1f} TB".format(size) + + +def response_content_length(response): + headers = getattr(response, "headers", None) + if headers is None: + return None + content_length = headers.get("Content-Length") + if not content_length: + return None + try: + return int(content_length) + except (TypeError, ValueError): + return None + + +def emit_download_progress(downloaded_bytes, total_bytes, output_stream): + if output_stream is None: + return + + if total_bytes: + progress = min(downloaded_bytes / float(total_bytes), 1.0) + filled = int(PROGRESS_BAR_WIDTH * progress) + bar = "#" * filled + "-" * (PROGRESS_BAR_WIDTH - filled) + line = "\r[{0}] {1:3.0f}% {2}/{3}".format( + bar, + progress * 100.0, + format_byte_count(downloaded_bytes), + format_byte_count(total_bytes), + ) + else: + line = "\rDownloaded {0}".format(format_byte_count(downloaded_bytes)) + + output_stream.write(line) + output_stream.flush() + + def fetch_models_markdown(models_url=MODELS_MD_URL, urlopen_fn=urlopen): request = Request( models_url, @@ -107,7 +157,7 @@ def prompt_for_choice(model_options, input_fn=input, output_fn=print): output_fn("Please choose medium, small, big, or none.") -def download_to_path(url, destination, urlopen_fn=urlopen): +def download_to_path(url, destination, urlopen_fn=urlopen, progress_stream=None): request = Request( url, headers={ @@ -115,11 +165,19 @@ def download_to_path(url, destination, urlopen_fn=urlopen): }, ) with urlopen_fn(request, timeout=60) as response, destination.open("wb") as output_file: + total_bytes = response_content_length(response) + downloaded_bytes = 0 + emit_download_progress(downloaded_bytes, total_bytes, progress_stream) while True: - chunk = response.read(1024 * 1024) + chunk = response.read(DOWNLOAD_CHUNK_SIZE) if not chunk: break output_file.write(chunk) + downloaded_bytes += len(chunk) + emit_download_progress(downloaded_bytes, total_bytes, progress_stream) + if progress_stream is not None: + progress_stream.write("\n") + progress_stream.flush() def find_model_directory(extract_root): @@ -151,7 +209,7 @@ def write_model_metadata(target_dir, model): metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") -def install_model_archive(model, repo_root, urlopen_fn=urlopen, temp_dir_parent=None): +def install_model_archive(model, repo_root, urlopen_fn=urlopen, temp_dir_parent=None, progress_stream=None): repo_root = Path(repo_root) target_dir = repo_root / MODEL_DIR_NAME existing_lexicon = None @@ -165,7 +223,7 @@ def install_model_archive(model, repo_root, urlopen_fn=urlopen, temp_dir_parent= extract_root = temp_dir / "extract" extract_root.mkdir() - download_to_path(model["url"], archive_path, urlopen_fn=urlopen_fn) + download_to_path(model["url"], archive_path, urlopen_fn=urlopen_fn, progress_stream=progress_stream) with zipfile.ZipFile(archive_path) as archive: archive.extractall(extract_root) @@ -220,7 +278,7 @@ def main(argv=None): model = model_options[choice] print("Downloading {0}: {1} ({2})".format(TIER_LABELS.get(choice, choice), model["name"], model["size"])) - target_dir = install_model_archive(model, args.repo_root) + target_dir = install_model_archive(model, args.repo_root, progress_stream=sys.stdout) print("Installed Kaldi model to {0}".format(target_dir)) return 0 except Exception as exc: diff --git a/tests/lib/test_kaldi_model.py b/tests/lib/test_kaldi_model.py index b9b02f3a3..6af994161 100644 --- a/tests/lib/test_kaldi_model.py +++ b/tests/lib/test_kaldi_model.py @@ -10,6 +10,7 @@ from castervoice.lib.kaldi_model import MODEL_DIR_NAME from castervoice.lib.kaldi_model import MODEL_METADATA_NAME from castervoice.lib.kaldi_model import USER_LEXICON_NAME +from castervoice.lib.kaldi_model import download_to_path from castervoice.lib.kaldi_model import install_model_archive from castervoice.lib.kaldi_model import parse_models_markdown from castervoice.lib.kaldi_model import select_latest_models_by_tier @@ -17,8 +18,9 @@ class _FakeResponse: - def __init__(self, payload): + def __init__(self, payload, headers=None): self._buffer = io.BytesIO(payload) + self.headers = headers or {} def __enter__(self): return self @@ -106,3 +108,43 @@ def test_install_model_archive_preserves_user_lexicon_and_writes_metadata(self): tmp_user_root.rmdir() except OSError: pass + + def test_download_to_path_emits_progress_bar_when_content_length_is_known(self): + destination = Path("tmp_kaldi_model_progress.bin") + progress_output = io.StringIO() + payload = b"x" * 32 + + try: + download_to_path( + "https://example.invalid/kaldi_model.zip", + destination, + urlopen_fn=lambda request, timeout=60: _FakeResponse(payload, headers={"Content-Length": str(len(payload))}), + progress_stream=progress_output, + ) + self.assertEqual(payload, destination.read_bytes()) + self.assertIn("[", progress_output.getvalue()) + self.assertIn("100%", progress_output.getvalue()) + self.assertIn("32 B/32 B", progress_output.getvalue()) + self.assertTrue(progress_output.getvalue().endswith("\n")) + finally: + if destination.exists(): + destination.unlink() + + def test_download_to_path_emits_downloaded_bytes_when_content_length_is_unknown(self): + destination = Path("tmp_kaldi_model_progress_unknown.bin") + progress_output = io.StringIO() + payload = b"x" * (2 * 1024 * 1024) + + try: + download_to_path( + "https://example.invalid/kaldi_model.zip", + destination, + urlopen_fn=lambda request, timeout=60: _FakeResponse(payload), + progress_stream=progress_output, + ) + self.assertEqual(payload, destination.read_bytes()) + self.assertIn("Downloaded 2.0 MB", progress_output.getvalue()) + self.assertTrue(progress_output.getvalue().endswith("\n")) + finally: + if destination.exists(): + destination.unlink() From 2a74c5a050b13bbbbee4f7e73c122fa282e30782 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 10:39:10 +1300 Subject: [PATCH 10/15] Reuse existing installer venvs on rerun --- Install_Caster_Kaldi.bat | 17 +++++++++++++++++ Install_Caster_WSR.bat | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Install_Caster_Kaldi.bat b/Install_Caster_Kaldi.bat index 836f9dc6f..336b815c0 100644 --- a/Install_Caster_Kaldi.bat +++ b/Install_Caster_Kaldi.bat @@ -21,6 +21,7 @@ set "kaldi_requirement_distribution=" set "local_dragonfly_source_url=" set "kaldi_requirement_warning=" set "kaldi_resolver_failed=" +set "existing_python_version=" echo Installation path: %currentpath% echo Installing Caster dependencies for Kaldi using the local uv virtualenv. @@ -32,13 +33,29 @@ if errorlevel 1 ( exit /b 1 ) +if exist "%runtime_python%" ( + set "python_version_file=%TEMP%\caster_python_version_%RANDOM%.txt" + "%runtime_python%" -c "import sys; print('{0}.{1}'.format(sys.version_info.major, sys.version_info.minor))" > "%python_version_file%" + if not errorlevel 1 set /p existing_python_version=<"%python_version_file%" + if exist "%python_version_file%" del /q "%python_version_file%" >nul 2>nul +) + +if "%existing_python_version%"=="%python_request%" goto :venv_ready + echo Creating or updating local virtualenv at %venv_dir% with uv-managed Python %python_request%... uv venv --allow-existing --managed-python --python "%python_request%" "%venv_dir%" if errorlevel 1 ( echo ERROR: Unable to create the local .venv with uv-managed Python 3.12. + echo If .venv is already in use, close running Caster, HUD, settings, and Homunculus processes and retry. echo Run: uv python install 3.12 exit /b 2 ) +goto :venv_created + +:venv_ready +echo Reusing existing local virtualenv at %venv_dir% with Python %existing_python_version%... + +:venv_created if not exist "%runtime_python%" ( echo ERROR: Failed to resolve the Python interpreter in the local .venv. diff --git a/Install_Caster_WSR.bat b/Install_Caster_WSR.bat index 3a9084c09..249cc69b0 100644 --- a/Install_Caster_WSR.bat +++ b/Install_Caster_WSR.bat @@ -9,6 +9,7 @@ set "python_request=3.12" set "installer_requirements=%currentpath%requirements-windows-installer.txt" set "dragonfly_source_probe=%currentpath%castervoice\lib\kaldi_wheel.py" set "local_dragonfly_source_url=" +set "existing_python_version=" echo Installation path: %currentpath% echo Installing Caster dependencies for WSR using the local uv virtualenv. @@ -20,13 +21,29 @@ if errorlevel 1 ( exit /b 1 ) +if exist "%runtime_python%" ( + set "python_version_file=%TEMP%\caster_python_version_%RANDOM%.txt" + "%runtime_python%" -c "import sys; print('{0}.{1}'.format(sys.version_info.major, sys.version_info.minor))" > "%python_version_file%" + if not errorlevel 1 set /p existing_python_version=<"%python_version_file%" + if exist "%python_version_file%" del /q "%python_version_file%" >nul 2>nul +) + +if "%existing_python_version%"=="%python_request%" goto :venv_ready + echo Creating or updating local virtualenv at %venv_dir% with uv-managed Python %python_request%... uv venv --allow-existing --managed-python --python "%python_request%" "%venv_dir%" if errorlevel 1 ( echo ERROR: Unable to create the local .venv with uv-managed Python 3.12. + echo If .venv is already in use, close running Caster, HUD, settings, and Homunculus processes and retry. echo Run: uv python install 3.12 exit /b 2 ) +goto :venv_created + +:venv_ready +echo Reusing existing local virtualenv at %venv_dir% with Python %existing_python_version%... + +:venv_created if not exist "%runtime_python%" ( echo ERROR: Failed to resolve the Python interpreter in the local .venv. From e9df7120d2360826f536177ba727b6a56bbd2cd4 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 12:43:27 +1300 Subject: [PATCH 11/15] Print detected user directory during startup --- _caster.py | 1 + castervoice/lib/settings.py | 25 ++++++++++++++++++++----- tests/lib/test_settings.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/_caster.py b/_caster.py index e597e9faa..789e03493 100644 --- a/_caster.py +++ b/_caster.py @@ -18,6 +18,7 @@ from castervoice.asynch import hud_support printer.out("@ - Starting {} with `{}` Engine -\n".format(settings.SOFTWARE_NAME, get_engine().name)) +settings.report_user_dir() DependencyMan().initialize() # requires nothing settings.initialize() diff --git a/castervoice/lib/settings.py b/castervoice/lib/settings.py index 6711dccc1..6c83e6995 100644 --- a/castervoice/lib/settings.py +++ b/castervoice/lib/settings.py @@ -42,6 +42,7 @@ WSR = False _BASE_PATH = None _USER_DIR = None +_USER_DIR_REPORTED = False _SETTINGS_PATH = None @@ -83,6 +84,23 @@ def get_filename(): return _SETTINGS_PATH +def detected_user_dir(): + configured_user_dir = os.getenv("CASTER_USER_DIR") + if configured_user_dir is not None: + return configured_user_dir + return user_data_dir(appname="caster", appauthor=False) + + +def report_user_dir(): + global _USER_DIR, _USER_DIR_REPORTED + if _USER_DIR is None: + _USER_DIR = detected_user_dir() + if not _USER_DIR_REPORTED: + printer.out("Caster User Directory: {}".format(_USER_DIR)) + _USER_DIR_REPORTED = True + return _USER_DIR + + def runtime_hidden_console_binary(): runtime_binary = "" if SYSTEM_INFORMATION is not None: @@ -493,10 +511,7 @@ def initialize(): # calculate prerequisites SYSTEM_INFORMATION = _get_platform_information() _BASE_PATH = str(Path(__file__).resolve().parent.parent) - if os.getenv("CASTER_USER_DIR") is not None: - _USER_DIR = os.getenv("CASTER_USER_DIR") - else: - _USER_DIR = user_data_dir(appname="caster", appauthor=False) + _USER_DIR = detected_user_dir() _SETTINGS_PATH = str(Path(_USER_DIR).joinpath("settings/settings.toml")) # Kick everything off. @@ -510,4 +525,4 @@ def initialize(): if _debugger_path not in sys.path and os.path.isdir(_debugger_path): sys.path.append(_debugger_path) - printer.out("Caster User Directory: {}".format(_USER_DIR)) + report_user_dir() diff --git a/tests/lib/test_settings.py b/tests/lib/test_settings.py index bf64639d5..00f95c693 100644 --- a/tests/lib/test_settings.py +++ b/tests/lib/test_settings.py @@ -15,6 +15,7 @@ def _reset_settings_state(self): settings.SYSTEM_INFORMATION = None settings._BASE_PATH = None settings._USER_DIR = None + settings._USER_DIR_REPORTED = False settings._SETTINGS_PATH = None def test_runtime_python_paths_removed_from_defaults(self): @@ -35,3 +36,19 @@ def test_runtime_hidden_console_binary_prefers_active_runtime(self): with patch("castervoice.lib.settings.os.path.isfile", side_effect=lambda path: path == runtime_pythonw): self.assertEqual(runtime_pythonw, settings.runtime_hidden_console_binary()) + + def test_detected_user_dir_prefers_environment_override(self): + with patch("castervoice.lib.settings.os.getenv", return_value="C:/Users/Main/CasterData"), \ + patch("castervoice.lib.settings.user_data_dir") as user_data_dir: + self.assertEqual("C:/Users/Main/CasterData", settings.detected_user_dir()) + + user_data_dir.assert_not_called() + + def test_report_user_dir_uses_default_location_once(self): + with patch("castervoice.lib.settings.os.getenv", return_value=None), \ + patch("castervoice.lib.settings.user_data_dir", return_value="C:/Users/Main/AppData/Local/caster"), \ + patch("castervoice.lib.settings.printer.out") as printer_out: + self.assertEqual("C:/Users/Main/AppData/Local/caster", settings.report_user_dir()) + self.assertEqual("C:/Users/Main/AppData/Local/caster", settings.report_user_dir()) + + printer_out.assert_called_once_with("Caster User Directory: C:/Users/Main/AppData/Local/caster") From 9295ba47eae043775e3dccc4e0007eeb6ae7ea14 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 13:05:55 +1300 Subject: [PATCH 12/15] Print user directory in Kaldi launcher --- Run_Caster_Kaldi.bat | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Run_Caster_Kaldi.bat b/Run_Caster_Kaldi.bat index ec4068310..a09ec6998 100644 --- a/Run_Caster_Kaldi.bat +++ b/Run_Caster_Kaldi.bat @@ -9,7 +9,11 @@ set "nltk_data_dir=%currentpath%.venv\nltk_data" TITLE Caster: Status Window if not exist "%runtime_python%" goto :missing_runtime_python +set "caster_user_dir=" +for /f "usebackq delims=" %%I in (`"%runtime_python%" -c "from castervoice.lib.settings import detected_user_dir; print(detected_user_dir())" 2^>nul`) do if not defined caster_user_dir set "caster_user_dir=%%I" + echo Using Kaldi interpreter: %runtime_python% +if defined caster_user_dir echo Detected Caster user directory: %caster_user_dir% if exist "%nltk_data_dir%" ( set "NLTK_DATA=%nltk_data_dir%" echo Using Kaldi pronunciation data: %nltk_data_dir% From c42df753588b61b24c1726298134c793f2d142c8 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 13:38:17 +1300 Subject: [PATCH 13/15] Fix Kaldi launcher user directory probe --- Run_Caster_Kaldi.bat | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Run_Caster_Kaldi.bat b/Run_Caster_Kaldi.bat index a09ec6998..009b3e306 100644 --- a/Run_Caster_Kaldi.bat +++ b/Run_Caster_Kaldi.bat @@ -5,12 +5,17 @@ SetLocal DisableDelayedExpansion set "currentpath=%~dp0" set "runtime_python=%currentpath%.venv\Scripts\python.exe" set "nltk_data_dir=%currentpath%.venv\nltk_data" +set "user_dir_probe_file=%TEMP%\caster-user-dir-%RANDOM%%RANDOM%.txt" TITLE Caster: Status Window if not exist "%runtime_python%" goto :missing_runtime_python set "caster_user_dir=" -for /f "usebackq delims=" %%I in (`"%runtime_python%" -c "from castervoice.lib.settings import detected_user_dir; print(detected_user_dir())" 2^>nul`) do if not defined caster_user_dir set "caster_user_dir=%%I" +"%runtime_python%" -c "from castervoice.lib.settings import detected_user_dir; print(detected_user_dir())" > "%user_dir_probe_file%" 2>nul +if exist "%user_dir_probe_file%" ( + set /p "caster_user_dir="<"%user_dir_probe_file%" + del "%user_dir_probe_file%" >nul 2>nul +) echo Using Kaldi interpreter: %runtime_python% if defined caster_user_dir echo Detected Caster user directory: %caster_user_dir% From 315b75a28b54f616f3361e28072aa58e9b1f321e Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Wed, 25 Mar 2026 13:51:55 +1300 Subject: [PATCH 14/15] Prefer CASTER_USER_DIR in Kaldi launcher --- Run_Caster_Kaldi.bat | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Run_Caster_Kaldi.bat b/Run_Caster_Kaldi.bat index 009b3e306..7421aba7a 100644 --- a/Run_Caster_Kaldi.bat +++ b/Run_Caster_Kaldi.bat @@ -10,11 +10,13 @@ set "user_dir_probe_file=%TEMP%\caster-user-dir-%RANDOM%%RANDOM%.txt" TITLE Caster: Status Window if not exist "%runtime_python%" goto :missing_runtime_python -set "caster_user_dir=" -"%runtime_python%" -c "from castervoice.lib.settings import detected_user_dir; print(detected_user_dir())" > "%user_dir_probe_file%" 2>nul -if exist "%user_dir_probe_file%" ( - set /p "caster_user_dir="<"%user_dir_probe_file%" - del "%user_dir_probe_file%" >nul 2>nul +set "caster_user_dir=%CASTER_USER_DIR%" +if not defined caster_user_dir ( + "%runtime_python%" -c "from appdirs import user_data_dir; print(user_data_dir(appname='caster', appauthor=False))" > "%user_dir_probe_file%" 2>nul + if exist "%user_dir_probe_file%" ( + set /p "caster_user_dir="<"%user_dir_probe_file%" + del "%user_dir_probe_file%" >nul 2>nul + ) ) echo Using Kaldi interpreter: %runtime_python% From d5057b9c0ece398d9bb23c27fadb861d10ff4731 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Tue, 31 Mar 2026 18:45:32 +1300 Subject: [PATCH 15/15] Allow configurable Kaldi audio devices --- Run_Caster_Kaldi.bat | 28 +++++++++++++-- castervoice/lib/kaldi_audio_device.py | 50 +++++++++++++++++++++++++++ castervoice/lib/utilities.py | 6 +++- docs/Installation/Windows/Kaldi.md | 16 +++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 castervoice/lib/kaldi_audio_device.py diff --git a/Run_Caster_Kaldi.bat b/Run_Caster_Kaldi.bat index 7421aba7a..4fd6ee6c5 100644 --- a/Run_Caster_Kaldi.bat +++ b/Run_Caster_Kaldi.bat @@ -1,11 +1,14 @@ @echo off echo Running Kaldi from Dragonfly CLI. -SetLocal DisableDelayedExpansion +SetLocal EnableDelayedExpansion set "currentpath=%~dp0" set "runtime_python=%currentpath%.venv\Scripts\python.exe" set "nltk_data_dir=%currentpath%.venv\nltk_data" set "user_dir_probe_file=%TEMP%\caster-user-dir-%RANDOM%%RANDOM%.txt" +set "audio_device_probe_file=%TEMP%\caster-audio-device-%RANDOM%%RANDOM%.txt" +set "configured_audio_input_device=%CASTER_KALDI_AUDIO_INPUT_DEVICE%" +set "configured_engine_options=%CASTER_KALDI_ENGINE_OPTIONS%" TITLE Caster: Status Window if not exist "%runtime_python%" goto :missing_runtime_python @@ -21,16 +24,37 @@ if not defined caster_user_dir ( echo Using Kaldi interpreter: %runtime_python% if defined caster_user_dir echo Detected Caster user directory: %caster_user_dir% +if defined caster_user_dir set "CASTER_USER_DIR=%caster_user_dir%" if exist "%nltk_data_dir%" ( set "NLTK_DATA=%nltk_data_dir%" echo Using Kaldi pronunciation data: %nltk_data_dir% ) -"%runtime_python%" -m dragonfly load _*.py --engine kaldi --no-recobs-messages --engine-options "model_dir=kaldi_model, vad_padding_end_ms=300" +set "kaldi_engine_options=model_dir=kaldi_model, vad_padding_end_ms=300" +if defined configured_audio_input_device ( + "%runtime_python%" -m castervoice.lib.kaldi_audio_device "!configured_audio_input_device!" > "%audio_device_probe_file%" + if errorlevel 1 goto :invalid_audio_device + if exist "%audio_device_probe_file%" ( + set /p "resolved_audio_input_device="<"%audio_device_probe_file%" + del "%audio_device_probe_file%" >nul 2>nul + ) + set "kaldi_engine_options=!kaldi_engine_options!, audio_input_device=!resolved_audio_input_device!" + echo Using Kaldi audio input device: !configured_audio_input_device! ^(PortAudio #!resolved_audio_input_device!^) +) +if defined configured_engine_options ( + set "kaldi_engine_options=!kaldi_engine_options!, !configured_engine_options!" + echo Using extra Kaldi engine options: !configured_engine_options! +) +"%runtime_python%" -m dragonfly load _*.py --engine kaldi --no-recobs-messages --engine-options "!kaldi_engine_options!" goto :after_run :missing_runtime_python echo ERROR: Local Caster virtualenv is missing: %runtime_python% echo Run Install_Caster_Kaldi.bat first to create .venv and install dependencies. +goto :after_run + +:invalid_audio_device +echo ERROR: Could not resolve Kaldi audio input device from CASTER_KALDI_AUDIO_INPUT_DEVICE=!configured_audio_input_device! +if exist "%audio_device_probe_file%" del "%audio_device_probe_file%" >nul 2>nul :after_run pause 1 diff --git a/castervoice/lib/kaldi_audio_device.py b/castervoice/lib/kaldi_audio_device.py new file mode 100644 index 000000000..a6822003b --- /dev/null +++ b/castervoice/lib/kaldi_audio_device.py @@ -0,0 +1,50 @@ +import sys + +import sounddevice + + +def _describe_device(index, device_info): + hostapi_name = sounddevice.query_hostapis(device_info["hostapi"])["name"] + return f"{device_info['name']}, {hostapi_name}" + + +def resolve_audio_input_device(device_spec): + try: + device_index = int(device_spec) + device_info = sounddevice.query_devices(device_index) + except ValueError: + device_info = sounddevice.query_devices(device_spec) + device_index = None + for index, candidate in enumerate(sounddevice.query_devices()): + if candidate["name"] == device_info["name"] and candidate["hostapi"] == device_info["hostapi"]: + device_index = index + break + if device_index is None: + raise ValueError(f"Unable to determine PortAudio index for {device_spec!r}") + + if device_info["max_input_channels"] <= 0: + description = _describe_device(device_index, device_info) + raise ValueError(f"Device {description!r} is not an input device") + + return device_index, device_info + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if len(argv) != 1: + print("Usage: python -m castervoice.lib.kaldi_audio_device ", file=sys.stderr) + return 2 + + device_spec = argv[0] + try: + device_index, _ = resolve_audio_input_device(device_spec) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + print(device_index) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/castervoice/lib/utilities.py b/castervoice/lib/utilities.py index 71b954979..dba998595 100644 --- a/castervoice/lib/utilities.py +++ b/castervoice/lib/utilities.py @@ -185,7 +185,11 @@ def reboot(): engine = get_current_engine() if engine.name == 'kaldi': engine.disconnect() - subprocess.Popen([sys.executable, '-m', 'dragonfly', 'load', '_*.py', '--engine', 'kaldi', '--no-recobs-messages']) + kaldi_launcher = Path(settings.SETTINGS["paths"]["BASE_PATH"]).parent / "Run_Caster_Kaldi.bat" + if kaldi_launcher.is_file(): + subprocess.Popen([str(kaldi_launcher)], cwd=str(kaldi_launcher.parent)) + else: + subprocess.Popen([sys.executable, '-m', 'dragonfly', 'load', '_*.py', '--engine', 'kaldi', '--no-recobs-messages']) if engine.name == 'sapi5inproc': engine.disconnect() subprocess.Popen([sys.executable, '-m', 'dragonfly', 'load', '--engine', 'sapi5inproc', '_*.py', '--no-recobs-messages']) diff --git a/docs/Installation/Windows/Kaldi.md b/docs/Installation/Windows/Kaldi.md index d9771967f..29112bbd9 100644 --- a/docs/Installation/Windows/Kaldi.md +++ b/docs/Installation/Windows/Kaldi.md @@ -43,6 +43,7 @@ Caster currently supports Kaldi on Microsoft Windows 10 through Windows 11. Cons **Note:** Kaldi is a flexible engine which can be configured via engine parameters to customize your experience. - `Run_Caster_Kaldi.bat` launches `dragonfly` with `--engine kaldi --no-recobs-messages --engine-options "model_dir=kaldi_model, vad_padding_end_ms=300"` using `.\.venv\Scripts\python.exe`. +- `Run_Caster_Kaldi.bat` also honors `CASTER_KALDI_AUDIO_INPUT_DEVICE` for pinning a specific PortAudio input device and `CASTER_KALDI_ENGINE_OPTIONS` for appending extra engine options without editing the batch file. - See the list of Kaldi [engine parameters](https://dragonfly2.readthedocs.io/en/latest/kaldi_engine.html#engine-configuration) for additional configuration options. ### Update Caster @@ -79,6 +80,21 @@ Caster currently supports Kaldi on Microsoft Windows 10 through Windows 11. Cons > fix: rerun `Install_Caster_Kaldi.bat` to recreate `.\.venv` with uv-managed Python and restore `.\.venv\Scripts\python.exe`. +- See repeated `no good block received recently, so reconnecting audio` warnings while Kaldi is listening. + + > This means the audio backend is opening a device successfully but not receiving enough valid 10 ms microphone blocks to keep streaming. On Windows this is often the default `MME` input for a Bluetooth headset. + > + > fix: + > + > 1. List the PortAudio input devices: + > `.\.venv\Scripts\python.exe -c "from dragonfly.engines.backend_kaldi.engine import KaldiEngine; KaldiEngine.print_mic_list()"` + > 2. Pick a more stable device entry for the same microphone, usually `Windows WASAPI`. + > 3. Launch Caster with that exact device name, for example in PowerShell: + > `$env:CASTER_KALDI_AUDIO_INPUT_DEVICE = "Headset (Example Device), Windows WASAPI"` + > `.\Run_Caster_Kaldi.bat` + > + > You can also pass a numeric PortAudio device index instead of the full device name by setting `CASTER_KALDI_AUDIO_INPUT_DEVICE` to that index. + **Known Issues** - Kaldi outputs a lot of text to the Caster status window on Windows.