diff --git a/.gitignore b/.gitignore index edbe43f85..d290a706a 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ kaldi_model* # Ignore virtual venv* +.venv/ # Dragonfly module loaders kaldi_module_loader_plus.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4572f0f0..f0cb056c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- Added `Install_Caster_WSR.bat` for WSR installs using `uv` and Python 3.12+. +- Migrated `Install_Caster_Kaldi.bat` and `Run_Caster_Kaldi.bat` to a `uv`-managed Python 3.12+ workflow on Windows. +- Updated WSR Windows installation docs to use `Install_Caster_WSR.bat`. +- Updated Windows Kaldi installation docs to use the interpreter metadata recorded during install. +- 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 path stored in user `settings/settings.toml`. +- Updated Kaldi scripts to launch with the same interpreter path stored in user `settings/settings.toml`. +- 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..336b815c0 100644 --- a/Install_Caster_Kaldi.bat +++ b/Install_Caster_Kaldi.bat @@ -1,14 +1,349 @@ @echo off -set currentpath=%~dp0 + +SetLocal DisableDelayedExpansion +set "currentpath=%~dp0" +for %%I in ("%currentpath%.") do set "repo_root=%%~fI" +set "kaldi_legacy_python_metadata=%currentpath%castervoice\bin\data\kaldi_python_path.txt" +set "venv_dir=%currentpath%.venv" +set "runtime_python=%venv_dir%\Scripts\python.exe" +set "python_request=3.12" +set "installer_requirements=%currentpath%requirements-windows-installer.txt" +set "kaldi_wheel_resolver=%currentpath%castervoice\lib\kaldi_wheel.py" +set "kaldi_model_installer=%currentpath%castervoice\lib\kaldi_model.py" +set "kaldi_install_source=" +set "kaldi_engine_version=" +set "kaldi_model_status=not-installed" +set "pronunciation_backend_status=not-installed" +set "nltk_data_dir=%venv_dir%\nltk_data" +set "qt_arch_supported=0" +set "local_dragonfly_distribution=" +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 Using this python/pip: -python -m pip -V +echo Installing Caster dependencies for Kaldi using the local uv virtualenv. + +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 +) + +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. + echo Expected: %runtime_python% + exit /b 6 +) + +echo Upgrading pip for Kaldi interpreter... +uv pip install --python "%runtime_python%" --upgrade pip +if errorlevel 1 ( + echo ERROR: Failed while upgrading pip for Kaldi interpreter. + exit /b 3 +) + +if not exist "%installer_requirements%" ( + echo ERROR: Installer requirements file not found at %installer_requirements%. + exit /b 4 +) + +echo Installing Caster dependencies for Kaldi... +uv pip install --python "%runtime_python%" -r "%installer_requirements%" +if errorlevel 1 ( + echo ERROR: Failed while installing dependencies from %installer_requirements%. + exit /b 4 +) + +if not exist "%kaldi_wheel_resolver%" ( + echo ERROR: Kaldi wheel resolver not found at %kaldi_wheel_resolver%. + exit /b 4 +) + +set "local_dragonfly_probe=%TEMP%\caster_local_dragonfly_%RANDOM%.txt" +"%runtime_python%" "%kaldi_wheel_resolver%" --detect-local-source-dragonfly > "%local_dragonfly_probe%" +if errorlevel 1 ( + if exist "%local_dragonfly_probe%" del /q "%local_dragonfly_probe%" >nul 2>nul + echo ERROR: Failed while checking for a local dragonfly source install. + exit /b 4 +) +for /f "usebackq tokens=1,* delims==" %%A in ("%local_dragonfly_probe%") do ( + if /i "%%A"=="local_dragonfly_distribution" set "local_dragonfly_distribution=%%B" + if /i "%%A"=="local_dragonfly_source_url" set "local_dragonfly_source_url=%%B" +) +if exist "%local_dragonfly_probe%" del /q "%local_dragonfly_probe%" >nul 2>nul + +if defined local_dragonfly_source_url ( + echo WARNING: Local dragonfly source install detected in %venv_dir%. + echo WARNING: Leaving it unchanged and using its compatibility metadata for Kaldi resolution. +) else ( + echo Installing Dragonfly runtime dependency... + uv pip install --python "%runtime_python%" "dragonfly2>=0.34.0" + if errorlevel 1 ( + echo ERROR: Failed while installing dragonfly2. + exit /b 4 + ) +) + +set "kaldi_wheel_info=%TEMP%\caster_kaldi_wheel_%RANDOM%.txt" +"%runtime_python%" "%kaldi_wheel_resolver%" > "%kaldi_wheel_info%" +if errorlevel 1 ( + set "kaldi_resolver_failed=1" +) + +for /f "usebackq tokens=1,* delims==" %%A in ("%kaldi_wheel_info%") do ( + if /i "%%A"=="tag_name" set "kaldi_release_tag=%%B" + if /i "%%A"=="asset_name" set "kaldi_wheel_asset=%%B" + if /i "%%A"=="browser_download_url" set "kaldi_wheel_url=%%B" + if /i "%%A"=="kaldi_requirement_distribution" set "kaldi_requirement_distribution=%%B" + if /i "%%A"=="local_dragonfly_source_url" set "local_dragonfly_source_url=%%B" + if /i "%%A"=="kaldi_requirement_warning" set "kaldi_requirement_warning=%%B" +) +if exist "%kaldi_wheel_info%" del /q "%kaldi_wheel_info%" >nul 2>nul + +if defined kaldi_resolver_failed ( + echo NOTICE: Unable to resolve the latest Dragonfly-compatible GitHub Kaldi wheel. + goto :install_kaldi_fallback +) + +if not defined kaldi_wheel_url ( + echo NOTICE: Dragonfly-compatible GitHub Kaldi wheel metadata was incomplete. + goto :install_kaldi_fallback +) + +if defined kaldi_requirement_warning echo WARNING: %kaldi_requirement_warning% + +echo Installing Kaldi engine support dependencies... +uv pip install --upgrade --python "%runtime_python%" "sounddevice==0.3.*" "webrtcvad-wheels==2.0.*" +if errorlevel 1 ( + echo NOTICE: Failed while installing Kaldi support dependencies for the GitHub wheel path. + goto :install_kaldi_fallback +) + +echo Installing Kaldi engine from GitHub release %kaldi_release_tag%... +echo Selected wheel: %kaldi_wheel_asset% +uv pip install --upgrade --python "%runtime_python%" "%kaldi_wheel_url%" +if errorlevel 1 ( + echo NOTICE: Failed while installing the GitHub Kaldi wheel. + goto :install_kaldi_fallback +) + +set "kaldi_version_file=%TEMP%\caster_kaldi_version_%RANDOM%.txt" +"%runtime_python%" -c "import importlib.metadata as md; print(md.version('kaldi-active-grammar'))" > "%kaldi_version_file%" +if errorlevel 1 ( + if exist "%kaldi_version_file%" del /q "%kaldi_version_file%" >nul 2>nul + echo NOTICE: Installed GitHub Kaldi wheel could not be verified. + goto :install_kaldi_fallback +) +set /p kaldi_engine_version=<"%kaldi_version_file%" +if exist "%kaldi_version_file%" del /q "%kaldi_version_file%" >nul 2>nul +set "kaldi_install_source=github-wheel" +goto :after_kaldi_install + +:install_kaldi_fallback +if /i "%kaldi_requirement_distribution%"=="dragonfly" goto :local_dragonfly_kaldi_failure +if not defined kaldi_requirement_distribution if /i "%local_dragonfly_distribution%"=="dragonfly" goto :local_dragonfly_kaldi_failure +echo Installing Kaldi engine dependencies via uv package fallback... +uv pip install --upgrade --python "%runtime_python%" "dragonfly2[kaldi]" +if errorlevel 1 ( + echo ERROR: Failed while installing dragonfly2[kaldi]. + exit /b 5 +) +set "kaldi_version_file=%TEMP%\caster_kaldi_version_%RANDOM%.txt" +"%runtime_python%" -c "import importlib.metadata as md; print(md.version('kaldi-active-grammar'))" > "%kaldi_version_file%" +if errorlevel 1 ( + if exist "%kaldi_version_file%" del /q "%kaldi_version_file%" >nul 2>nul + echo ERROR: Failed while verifying the fallback Kaldi package installation. + exit /b 5 +) +set /p kaldi_engine_version=<"%kaldi_version_file%" +if exist "%kaldi_version_file%" del /q "%kaldi_version_file%" >nul 2>nul +set "kaldi_install_source=package-fallback" +goto :after_kaldi_install + +:local_dragonfly_kaldi_failure +echo ERROR: Local dragonfly compatibility metadata was detected, but the matching GitHub Kaldi wheel path did not complete successfully. +echo ERROR: Aborting instead of falling back to dragonfly2[kaldi], which may install an incompatible Kaldi stack. +if defined local_dragonfly_source_url echo ERROR: Local dragonfly source: %local_dragonfly_source_url% +exit /b 5 + +:after_kaldi_install +if not defined kaldi_engine_version set "kaldi_engine_version=unknown" +echo Installed kaldi-active-grammar version: %kaldi_engine_version% +echo Kaldi install source: %kaldi_install_source% + +echo Installing Kaldi pronunciation generation dependencies... +uv pip install --upgrade --python "%runtime_python%" "g2p_en>=2.1.0" +if errorlevel 1 ( + echo WARNING: Failed while installing g2p_en for local pronunciation generation. + echo WARNING: Unknown-word auto-pronunciation may be unavailable until g2p_en is installed manually. + set "pronunciation_backend_status=failed" + goto :after_pronunciation_backend +) + +if not exist "%nltk_data_dir%" mkdir "%nltk_data_dir%" >nul 2>nul +set "nltk_bootstrap_script=%TEMP%\caster_kaldi_nltk_%RANDOM%.py" +> "%nltk_bootstrap_script%" echo import os, sys +>> "%nltk_bootstrap_script%" echo import nltk +>> "%nltk_bootstrap_script%" echo nltk_data_dir = os.path.abspath(sys.argv[1]) +>> "%nltk_bootstrap_script%" echo os.makedirs(nltk_data_dir, exist_ok=True) +>> "%nltk_bootstrap_script%" echo missing = [] +>> "%nltk_bootstrap_script%" echo for package in ["cmudict", "averaged_perceptron_tagger"]: +>> "%nltk_bootstrap_script%" echo if not nltk.download(package, download_dir=nltk_data_dir, quiet=True): +>> "%nltk_bootstrap_script%" echo missing.append(package) +>> "%nltk_bootstrap_script%" echo for package in ["averaged_perceptron_tagger_eng"]: +>> "%nltk_bootstrap_script%" echo nltk.download(package, download_dir=nltk_data_dir, quiet=True) +>> "%nltk_bootstrap_script%" echo if missing: +>> "%nltk_bootstrap_script%" echo raise SystemExit("Failed to download required NLTK data: " + ", ".join(missing)) +"%runtime_python%" "%nltk_bootstrap_script%" "%nltk_data_dir%" +set "nltk_bootstrap_exit=%ERRORLEVEL%" +if exist "%nltk_bootstrap_script%" del /q "%nltk_bootstrap_script%" >nul 2>nul +if not "%nltk_bootstrap_exit%"=="0" ( + echo WARNING: Failed while downloading required NLTK data for g2p_en. + echo WARNING: Unknown-word auto-pronunciation may be unavailable until the NLTK data is restored. + set "pronunciation_backend_status=failed" + goto :after_pronunciation_backend +) + +set "g2p_verify_script=%TEMP%\caster_kaldi_g2p_%RANDOM%.py" +> "%g2p_verify_script%" echo import os, sys +>> "%g2p_verify_script%" echo os.environ["NLTK_DATA"] = os.path.abspath(sys.argv[1]) +>> "%g2p_verify_script%" echo from g2p_en import G2p +>> "%g2p_verify_script%" echo phones = G2p()("sikuli") +>> "%g2p_verify_script%" echo if not phones: +>> "%g2p_verify_script%" echo raise SystemExit("g2p_en returned no phones") +"%runtime_python%" "%g2p_verify_script%" "%nltk_data_dir%" +set "g2p_verify_exit=%ERRORLEVEL%" +if exist "%g2p_verify_script%" del /q "%g2p_verify_script%" >nul 2>nul +if not "%g2p_verify_exit%"=="0" ( + echo WARNING: g2p_en was installed, but pronunciation generation could not be verified. + echo WARNING: Unknown-word auto-pronunciation may still be unavailable until the local NLTK data is fixed. + set "pronunciation_backend_status=failed" + goto :after_pronunciation_backend +) + +set "pronunciation_backend_status=installed" +echo Kaldi pronunciation generation backend installed. + +:after_pronunciation_backend + +set "py_bits_file=%TEMP%\caster_py_bits_%RANDOM%.txt" +"%runtime_python%" -c "import struct; print(8*struct.calcsize('P'))" > "%py_bits_file%" +if errorlevel 1 ( + if exist "%py_bits_file%" del /q "%py_bits_file%" >nul 2>nul + echo ERROR: Failed to determine Python bitness for Qt dependency installation. + exit /b 7 +) +set /p py_bits=<"%py_bits_file%" +if exist "%py_bits_file%" del /q "%py_bits_file%" >nul 2>nul +if not defined py_bits ( + echo ERROR: Failed to determine Python bitness for Qt dependency installation. + exit /b 7 +) + +if "%py_bits%"=="64" ( + set "qt_arch_supported=1" + goto :install_qt +) +if "%py_bits%"=="32" goto :skip_qt_32 +goto :skip_qt_other + +:install_qt +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 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, 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, settings-window, and HMC features that require Qt may be unavailable. + +:after_qt + +echo. +if not exist "%kaldi_model_installer%" ( + echo NOTICE: Kaldi model helper not found at %kaldi_model_installer%. + set "kaldi_model_status=helper-missing" + goto :after_model +) + +choice /c YN /n /m "Download a Kaldi model now? [Y]es [N]o: " +if errorlevel 2 goto :skip_model_download + +"%runtime_python%" "%kaldi_model_installer%" --repo-root "%repo_root%" +set "kaldi_model_exit=%ERRORLEVEL%" +if "%kaldi_model_exit%"=="0" ( + set "kaldi_model_status=installed" + goto :after_model +) +if "%kaldi_model_exit%"=="2" ( + set "kaldi_model_status=skipped" + goto :after_model +) +echo WARNING: Failed while downloading or installing a Kaldi model. +echo WARNING: You can rerun Install_Caster_Kaldi.bat later to download one. +set "kaldi_model_status=failed" +goto :after_model + +:skip_model_download +set "kaldi_model_status=skipped" + +:after_model -echo Installing Caster Dependencies -py -m pip install -r "%currentpath%requirements.txt" -py -m pip install dragonfly2[kaldi] +if exist "%kaldi_legacy_python_metadata%" del /q "%kaldi_legacy_python_metadata%" >nul 2>nul +goto :install_complete -echo Remember: Manually install kaldi a model. -echo See Caster kaldi install instructions on ReadTheDocs. +:install_complete +echo. +echo Kaldi dependency installation completed successfully. +echo Kaldi virtualenv: %venv_dir% +echo Kaldi runtime interpreter: %runtime_python% +if "%pronunciation_backend_status%"=="installed" echo Kaldi pronunciation data: %nltk_data_dir% +if "%pronunciation_backend_status%"=="failed" echo WARNING: Kaldi pronunciation generation is not fully installed. Unknown words may require manual user_lexicon entries. +if "%kaldi_model_status%"=="installed" echo Kaldi model directory: %repo_root%\kaldi_model +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: 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 diff --git a/Install_Caster_WSR.bat b/Install_Caster_WSR.bat new file mode 100644 index 000000000..249cc69b0 --- /dev/null +++ b/Install_Caster_WSR.bat @@ -0,0 +1,154 @@ +@echo off + +SetLocal DisableDelayedExpansion +set "currentpath=%~dp0" +set "wsr_legacy_python_metadata=%currentpath%castervoice\bin\data\wsr_python_path.txt" +set "venv_dir=%currentpath%.venv" +set "runtime_python=%venv_dir%\Scripts\python.exe" +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. + +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 +) + +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. + echo Expected: %runtime_python% + exit /b 6 +) + +echo Upgrading pip for WSR interpreter... +uv pip install --python "%runtime_python%" --upgrade pip +if errorlevel 1 ( + echo ERROR: Failed while upgrading pip for WSR interpreter. + exit /b 3 +) + +if not exist "%installer_requirements%" ( + echo ERROR: Installer requirements file not found at %installer_requirements%. + exit /b 4 +) + +echo Installing Caster dependencies for WSR... +uv pip install --python "%runtime_python%" -r "%installer_requirements%" +if errorlevel 1 ( + echo ERROR: Failed while installing dependencies from %installer_requirements%. + exit /b 4 +) + +if not exist "%dragonfly_source_probe%" ( + echo ERROR: Dragonfly source detection helper not found at %dragonfly_source_probe%. + exit /b 4 +) + +set "local_dragonfly_probe=%TEMP%\caster_local_dragonfly_%RANDOM%.txt" +"%runtime_python%" "%dragonfly_source_probe%" --detect-local-source-dragonfly > "%local_dragonfly_probe%" +if errorlevel 1 ( + if exist "%local_dragonfly_probe%" del /q "%local_dragonfly_probe%" >nul 2>nul + echo ERROR: Failed while checking for a local dragonfly source install. + exit /b 4 +) +for /f "usebackq tokens=1,* delims==" %%A in ("%local_dragonfly_probe%") do ( + if /i "%%A"=="local_dragonfly_source_url" set "local_dragonfly_source_url=%%B" +) +if exist "%local_dragonfly_probe%" del /q "%local_dragonfly_probe%" >nul 2>nul + +if defined local_dragonfly_source_url ( + echo WARNING: Local dragonfly source install detected in %venv_dir%. + echo WARNING: Leaving it unchanged instead of installing dragonfly2. +) else ( + echo Installing Dragonfly runtime dependency... + uv pip install --python "%runtime_python%" "dragonfly2>=0.34.0" + if errorlevel 1 ( + echo ERROR: Failed while installing dragonfly2. + exit /b 4 + ) +) + +set "py_bits_file=%TEMP%\caster_py_bits_%RANDOM%.txt" +"%runtime_python%" -c "import struct; print(8*struct.calcsize('P'))" > "%py_bits_file%" +if errorlevel 1 ( + if exist "%py_bits_file%" del /q "%py_bits_file%" >nul 2>nul + echo ERROR: Failed to determine Python bitness for Qt dependency installation. + exit /b 7 +) +set /p py_bits=<"%py_bits_file%" +if exist "%py_bits_file%" del /q "%py_bits_file%" >nul 2>nul +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 --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. +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 + +if exist "%wsr_legacy_python_metadata%" del /q "%wsr_legacy_python_metadata%" >nul 2>nul +goto :install_complete + +:install_complete + +echo. +echo WSR dependency installation completed successfully. +echo WSR virtualenv: %venv_dir% +echo WSR runtime interpreter: %runtime_python% +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/MANIFEST.in b/MANIFEST.in index 6fa3eaaae..970070107 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,6 +15,7 @@ include castervoice/asynch/sikuli/server/xmlrpc_server.sikuli/xmlrpc_server.html include castervoice/asynch/sikuli/server/xmlrpc_server.sikuli/xmlrpc_server.py include castervoice/lib/github_automation.ahk include requirements.txt +include requirements-windows-installer.txt include requirements-dev.txt include requirements-mac-linux.txt -include post_setup.py \ No newline at end of file +include post_setup.py diff --git a/Run_Caster_Kaldi.bat b/Run_Caster_Kaldi.bat index 3d398af25..4fd6ee6c5 100644 --- a/Run_Caster_Kaldi.bat +++ b/Run_Caster_Kaldi.bat @@ -1,9 +1,60 @@ @echo off -echo Running Kaldi from Dragonfly CLI +echo Running Kaldi from Dragonfly CLI. -set currentpath=%~dp0 +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 -py -m dragonfly load _*.py --engine kaldi --no-recobs-messages --engine-options "model_dir=kaldi_model, vad_padding_end_ms=300" +if not exist "%runtime_python%" goto :missing_runtime_python +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% +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% +) +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/Run_Caster_WSR.bat b/Run_Caster_WSR.bat index 02280b510..c16a85d5b 100644 --- a/Run_Caster_WSR.bat +++ b/Run_Caster_WSR.bat @@ -1,9 +1,20 @@ @echo off -echo Running WRS from Dragonfly CLI. +echo Running WSR from Dragonfly CLI. -set currentpath=%~dp0 +SetLocal DisableDelayedExpansion +set "currentpath=%~dp0" +set "runtime_python=%currentpath%.venv\Scripts\python.exe" TITLE Caster: Status Window -py -m dragonfly load --engine sapi5inproc _*.py --no-recobs-messages +if not exist "%runtime_python%" goto :missing_runtime_python +echo Using WSR interpreter: %runtime_python% +"%runtime_python%" -m dragonfly load --engine sapi5inproc _*.py --no-recobs-messages +goto :after_run + +:missing_runtime_python +echo ERROR: Local Caster virtualenv is missing: %runtime_python% +echo Run Install_Caster_WSR.bat first to create .venv and install dependencies. + +:after_run pause 1 diff --git a/_caster.py b/_caster.py index 7aff0eeeb..789e03493 100644 --- a/_caster.py +++ b/_caster.py @@ -4,6 +4,10 @@ ''' import logging import importlib +from castervoice.lib.inspect_compat import ensure_getargspec + +ensure_getargspec() + from dragonfly import get_engine, get_current_engine from castervoice.lib import control from castervoice.lib import settings @@ -14,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/__init__.py b/castervoice/__init__.py index b4fdb8fe7..4426dfe98 100644 --- a/castervoice/__init__.py +++ b/castervoice/__init__.py @@ -1 +1,6 @@ -name = "castervoice" \ No newline at end of file +from castervoice.lib.inspect_compat import ensure_getargspec + + +ensure_getargspec() + +name = "castervoice" diff --git a/castervoice/asynch/hmc/h_launch.py b/castervoice/asynch/hmc/h_launch.py index 9a2f1c5b1..1709d1e61 100644 --- a/castervoice/asynch/hmc/h_launch.py +++ b/castervoice/asynch/hmc/h_launch.py @@ -26,12 +26,12 @@ def launch(hmc_type, data=None): def _get_instructions(hmc_type): if hmc_type == settings.QTTYPE_SETTINGS: return [ - settings.SETTINGS["paths"]["PYTHONW"], + settings.runtime_hidden_console_binary(), settings.SETTINGS["paths"]["SETTINGS_WINDOW_PATH"] ] else: return [ - settings.SETTINGS["paths"]["PYTHONW"], + settings.runtime_hidden_console_binary(), settings.SETTINGS["paths"]["HOMUNCULUS_PATH"], hmc_type ] diff --git a/castervoice/asynch/hud_support.py b/castervoice/asynch/hud_support.py index 8792d18c5..5f7dfc7a6 100644 --- a/castervoice/asynch/hud_support.py +++ b/castervoice/asynch/hud_support.py @@ -19,7 +19,7 @@ def start_hud(): try: hud.ping() except Exception: - subprocess.Popen([settings.SETTINGS["paths"]["PYTHONW"], + subprocess.Popen([settings.runtime_hidden_console_binary(), settings.SETTINGS["paths"]["HUD_PATH"]]) @@ -129,4 +129,4 @@ def handle_message(self, items): printer.out("Hud not available. \n{}".format(e)) raise("") # pylint: disable=raising-bad-type else: - raise("") # pylint: disable=raising-bad-type \ No newline at end of file + raise("") # pylint: disable=raising-bad-type diff --git a/castervoice/lib/ctrl/dependencies.py b/castervoice/lib/ctrl/dependencies.py index 74bb8fb3c..f6c34fbf1 100644 --- a/castervoice/lib/ctrl/dependencies.py +++ b/castervoice/lib/ctrl/dependencies.py @@ -3,13 +3,104 @@ @author: synkarius ''' -import os, sys, time -from importlib.metadata import version, PackageNotFoundError -from packaging.version import Version +import os +import sys +import time +from importlib import metadata +from importlib.metadata import PackageNotFoundError, version + +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 + from castervoice.lib import printer DARWIN = sys.platform == "darwin" LINUX = sys.platform == "linux" +DIST_ALIAS_MAP = { + "dragonfly2": ("dragonfly2", "dragonfly"), + "dragonfly": ("dragonfly", "dragonfly2"), +} + + +def _installed_distribution(distribution_name): + candidates = [] + primary_names = DIST_ALIAS_MAP.get(canonicalize_name(distribution_name), (distribution_name,)) + for primary_name in primary_names: + candidates.extend(( + primary_name, + primary_name.replace("_", "-"), + primary_name.replace("-", "_"), + canonicalize_name(primary_name), + )) + seen = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + try: + return metadata.distribution(candidate) + except PackageNotFoundError: + continue + raise PackageNotFoundError(distribution_name) + + +def _requirement_is_installed(requirement_spec, marker_environment=None, visited=None): + requirement = Requirement(requirement_spec) + marker_environment = dict(marker_environment or default_environment()) + if requirement.marker and not requirement.marker.evaluate(marker_environment): + return True + + 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 True + visited.add(visited_key) + + try: + distribution = _installed_distribution(requirement.name) + except PackageNotFoundError: + return False + + if requirement.specifier: + try: + installed_version = Version(distribution.version) + except InvalidVersion: + return False + if installed_version not in requirement.specifier: + return False + + for child_spec in distribution.requires or []: + if not _requirement_is_installed(child_spec, marker_environment, visited): + return False + + for extra in requirement.extras: + extra_environment = dict(marker_environment) + extra_environment["extra"] = extra + for child_spec in distribution.requires or []: + if not _requirement_is_installed(child_spec, extra_environment, visited): + return False + + return True + + +def _install_hint(requirement_spec): + requirement = Requirement(requirement_spec) + extras = "[{0}]".format(",".join(sorted(requirement.extras))) if requirement.extras else "" + return "{0}{1}{2}".format(requirement.name, extras, requirement.specifier) def install_type(): # Checks if Caster install is Classic or PIP. @@ -36,13 +127,14 @@ def dep_missing(): with open(requirements) as f: requirements = f.read().splitlines() for dep in requirements: - dep = dep.split(">=", 1)[0] - try: - version(dep) - except PackageNotFoundError: - missing_list.append(dep) + dep = dep.strip() + if not dep or dep.startswith("#"): + continue + if not _requirement_is_installed(dep): + missing_list.append(_install_hint(dep)) 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) @@ -60,7 +152,7 @@ def dep_min_version(): req_version = dep[2] issue_url = dep[3] try: - installed = Version(version(package)) + installed = Version(_installed_distribution(package).version) required = Version(req_version) if operator == ">=" and installed < required: if issue_url is not None: diff --git a/castervoice/lib/inspect_compat.py b/castervoice/lib/inspect_compat.py new file mode 100644 index 000000000..7d3fdddaf --- /dev/null +++ b/castervoice/lib/inspect_compat.py @@ -0,0 +1,19 @@ +import collections +import inspect + + +_ARG_SPEC = collections.namedtuple("ArgSpec", "args varargs keywords defaults") + + +def ensure_getargspec(inspect_module=inspect): + if hasattr(inspect_module, "getargspec"): + return + + arg_spec = getattr(inspect_module, "ArgSpec", _ARG_SPEC) + inspect_module.ArgSpec = arg_spec + + def getargspec(function): + full = inspect_module.getfullargspec(function) + return arg_spec(full.args, full.varargs, full.varkw, full.defaults) + + inspect_module.getargspec = getargspec 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/kaldi_model.py b/castervoice/lib/kaldi_model.py new file mode 100644 index 000000000..e3174a659 --- /dev/null +++ b/castervoice/lib/kaldi_model.py @@ -0,0 +1,290 @@ +import argparse +import json +import os +import re +import shutil +import sys +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from urllib.request import Request, urlopen + + +MODELS_MD_URL = "https://raw.githubusercontent.com/daanzu/kaldi-active-grammar/master/docs/models.md" +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)", + "small": "Smaller download", + "big": "Largest model", +} + + +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, + headers={ + "User-Agent": "Caster Kaldi Installer", + }, + ) + with urlopen_fn(request, timeout=30) as response: + return response.read().decode("utf-8") + + +def parse_models_markdown(markdown_text): + models = [] + for index, line in enumerate(markdown_text.splitlines()): + match = re.match(r'^\s*\*\s+\[(kaldi_model_[^\]]+)\]\((https://[^)]+\.zip)\)\s+\(([^)]+)\)', line) + if not match: + continue + model_name, url, size = match.groups() + tier_match = re.search(r'-(smalllm|mediumlm|biglm)$', model_name) + if not tier_match: + continue + source_release = "" + release_match = re.search(r"/download/([^/]+)/", url) + if release_match: + source_release = release_match.group(1) + date_match = re.search(r"_(\d{8})", model_name) + models.append( + { + "index": index, + "name": model_name, + "tier": tier_match.group(1).replace("lm", ""), + "size": size, + "url": url, + "source_release": source_release, + "date": date_match.group(1) if date_match else "", + } + ) + return models + + +def select_latest_models_by_tier(models): + selected = {} + for model in models: + selected.setdefault(model["tier"], model) + return selected + + +def prompt_for_choice(model_options, input_fn=input, output_fn=print): + available = [tier for tier in ("medium", "small", "big") if tier in model_options] + if not available: + raise ModelResolutionError("No Kaldi models were available from the upstream models list.") + + output_fn("Available Kaldi models:") + for tier in available: + model = model_options[tier] + output_fn( + " {0}: {1} ({2}, source {3})".format( + TIER_LABELS[tier], + model["name"], + model["size"], + model["source_release"] or "unknown release", + ) + ) + + prompt = "Select model [M]edium, [S]mall, [B]ig, or [N]one: " + while True: + selection = input_fn(prompt).strip().lower() + if not selection: + return "medium" if "medium" in model_options else available[0] + if selection in ("n", "no", "none", "skip"): + return None + if selection in ("m", "medium") and "medium" in model_options: + return "medium" + if selection in ("s", "small") and "small" in model_options: + return "small" + if selection in ("b", "big") and "big" in model_options: + return "big" + output_fn("Please choose medium, small, big, or none.") + + +def download_to_path(url, destination, urlopen_fn=urlopen, progress_stream=None): + request = Request( + url, + headers={ + "User-Agent": "Caster Kaldi Installer", + }, + ) + 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(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): + direct = extract_root / MODEL_DIR_NAME + if direct.is_dir(): + return direct + + prefixed_dirs = [path for path in extract_root.iterdir() if path.is_dir() and path.name.startswith(MODEL_DIR_NAME)] + if len(prefixed_dirs) == 1: + return prefixed_dirs[0] + + nested = [path for path in extract_root.rglob(MODEL_DIR_NAME) if path.is_dir()] + if len(nested) == 1: + return nested[0] + + raise ModelResolutionError("Could not determine the Kaldi model directory from the downloaded archive.") + + +def write_model_metadata(target_dir, model): + metadata = { + "installed_at": datetime.now(timezone.utc).isoformat(), + "model_name": model["name"], + "tier": model["tier"], + "size": model["size"], + "source_release": model["source_release"], + "url": model["url"], + } + metadata_path = target_dir / MODEL_METADATA_NAME + 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, progress_stream=None): + repo_root = Path(repo_root) + target_dir = repo_root / MODEL_DIR_NAME + existing_lexicon = None + lexicon_path = target_dir / USER_LEXICON_NAME + if lexicon_path.exists(): + existing_lexicon = lexicon_path.read_bytes() + + with tempfile.TemporaryDirectory(prefix="caster_kaldi_model_", dir=temp_dir_parent) as temp_dir_name: + temp_dir = Path(temp_dir_name) + archive_path = temp_dir / "model.zip" + extract_root = temp_dir / "extract" + extract_root.mkdir() + + 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) + + source_dir = find_model_directory(extract_root) + staged_target = temp_dir / MODEL_DIR_NAME + shutil.copytree(source_dir, staged_target) + + if existing_lexicon is not None: + (staged_target / USER_LEXICON_NAME).write_bytes(existing_lexicon) + + write_model_metadata(staged_target, model) + + if target_dir.exists(): + shutil.rmtree(target_dir) + shutil.move(str(staged_target), str(target_dir)) + + return target_dir + + +def resolve_model_options(models_url=MODELS_MD_URL, urlopen_fn=urlopen): + markdown = fetch_models_markdown(models_url=models_url, urlopen_fn=urlopen_fn) + models = parse_models_markdown(markdown) + return select_latest_models_by_tier(models) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description="Download and install a Kaldi speech model for Caster.") + parser.add_argument("--repo-root", required=True) + parser.add_argument("--models-url", default=MODELS_MD_URL) + parser.add_argument("--choice", choices=["medium", "small", "big", "skip"], default=None) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + + try: + model_options = resolve_model_options(models_url=args.models_url) + choice = args.choice or os.environ.get("CASTER_KALDI_MODEL") + if choice: + choice = choice.strip().lower() + if choice == "skip": + print("Skipping Kaldi model download.") + return 2 + if choice not in model_options: + raise ModelResolutionError("Requested Kaldi model '{0}' is not available.".format(choice)) + else: + choice = prompt_for_choice(model_options) + if choice is None: + print("Skipping Kaldi model download.") + return 2 + + 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, progress_stream=sys.stdout) + print("Installed Kaldi model to {0}".format(target_dir)) + return 0 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/castervoice/lib/kaldi_wheel.py b/castervoice/lib/kaldi_wheel.py new file mode 100644 index 000000000..348059c71 --- /dev/null +++ b/castervoice/lib/kaldi_wheel.py @@ -0,0 +1,429 @@ +import argparse +import json +import platform +import sys +from importlib import metadata +from importlib.metadata import PackageNotFoundError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +try: + from packaging.requirements import Requirement + from packaging.specifiers import SpecifierSet + 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.specifiers import SpecifierSet # pylint: disable=import-error + from pip._vendor.packaging.version import InvalidVersion, Version # pylint: disable=import-error + + +RELEASES_URL = "https://api.github.com/repos/daanzu/kaldi-active-grammar/releases" +KALDI_PACKAGE_NAME = "kaldi-active-grammar" +DEFAULT_DRAGONFLY_DISTRIBUTION = "dragonfly2" +LOCAL_DRAGONFLY_DISTRIBUTION = "dragonfly" + + +class WheelResolutionError(RuntimeError): + pass + + +def normalize_system(system_name): + system_name = (system_name or "").lower() + if system_name.startswith("win"): + return "windows" + if system_name.startswith("linux"): + return "linux" + if system_name in ("darwin", "mac", "macos", "osx"): + return "darwin" + return system_name + + +def normalize_machine(machine_name): + machine_name = (machine_name or "").lower() + aliases = { + "amd64": "x86_64", + "x86_64": "x86_64", + "x64": "x86_64", + "arm64": "arm64", + "aarch64": "arm64", + "x86": "x86", + "i386": "x86", + "i686": "x86", + } + return aliases.get(machine_name, machine_name) + + +def parse_wheel_filename(asset_name): + if not asset_name.endswith(".whl"): + return None + parts = asset_name[:-4].split("-") + if len(parts) < 5: + return None + try: + version = Version(parts[-4]) + except InvalidVersion: + return None + py_tag, abi_tag, plat_tag = parts[-3:] + return { + "name": asset_name, + "version": version, + "py_tags": py_tag.split("."), + "abi_tags": abi_tag.split("."), + "plat_tags": plat_tag.split("."), + } + + +def python_tags_match(py_tags, version_info): + major, minor = version_info[:2] + exact_tags = {"cp{0}{1}".format(major, minor), "py{0}{1}".format(major, minor)} + for tag in py_tags: + if tag == "py{0}".format(major): + return True + if tag in exact_tags: + return True + return False + + +def python_tag_priority(py_tags, version_info): + major, minor = version_info[:2] + if "cp{0}{1}".format(major, minor) in py_tags: + return 2 + if "py{0}{1}".format(major, minor) in py_tags: + return 2 + if "py{0}".format(major) in py_tags: + return 1 + return 0 + + +def platform_tags_match(plat_tags, system_name, machine_name): + machine_name = normalize_machine(machine_name) + system_name = normalize_system(system_name) + if system_name == "windows": + if machine_name == "x86_64": + return "win_amd64" in plat_tags + if machine_name == "x86": + return "win32" in plat_tags + if machine_name == "arm64": + return "win_arm64" in plat_tags + return False + if system_name == "linux": + if machine_name == "x86_64": + return any(tag.endswith("_x86_64") for tag in plat_tags) + if machine_name == "arm64": + return any(tag.endswith("_aarch64") for tag in plat_tags) + return False + if system_name == "darwin": + if machine_name == "x86_64": + return any(tag.endswith("_x86_64") for tag in plat_tags) + if machine_name == "arm64": + return any(tag.endswith("_arm64") for tag in plat_tags) + return False + return False + + +def normalize_releases_payload(payload): + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + return [payload] + raise WheelResolutionError("Unexpected release payload returned by GitHub API") + + +def normalize_kaldi_requirement_specifier(version_specifier): + if version_specifier is None: + return SpecifierSet() + return SpecifierSet(str(version_specifier)) + + +def get_distribution(distribution_name): + try: + return metadata.distribution(distribution_name) + except PackageNotFoundError: + return None + + +def distribution_direct_url(distribution): + try: + direct_url_text = distribution.read_text("direct_url.json") + except FileNotFoundError: # pragma: no cover - importlib.metadata may raise on some Python versions + return None + if not direct_url_text: + return None + try: + return json.loads(direct_url_text) + except (TypeError, ValueError): + return None + + +def local_directory_source_url(distribution): + direct_url = distribution_direct_url(distribution) + if not isinstance(direct_url, dict): + return "" + parsed = urlparse(direct_url.get("url", "")) + if parsed.scheme != "file": + return "" + if not isinstance(direct_url.get("dir_info"), dict): + return "" + return direct_url.get("url", "") + + +def dragonfly_distribution_candidates(distribution_name=None): + if distribution_name not in (None, "", "auto"): + return (distribution_name,) + return (DEFAULT_DRAGONFLY_DISTRIBUTION, LOCAL_DRAGONFLY_DISTRIBUTION) + + +def detect_local_source_dragonfly_distribution(distribution_name=None): + explicit_distribution = distribution_name not in (None, "", "auto") + + for candidate_name in dragonfly_distribution_candidates(distribution_name): + distribution = get_distribution(candidate_name) + source_url = local_directory_source_url(distribution) if distribution is not None else "" + if distribution is None or not source_url: + continue + + warning = "" + if not explicit_distribution and candidate_name == LOCAL_DRAGONFLY_DISTRIBUTION: + warning = ( + "Local dragonfly install detected; using its Kaldi compatibility metadata " + "instead of dragonfly2." + ) + + return { + "distribution": distribution, + "distribution_name": candidate_name, + "source_url": source_url, + "warning": warning, + } + + return None + + +def resolve_dragonfly_distribution(distribution_name=None): + local_source_distribution = detect_local_source_dragonfly_distribution(distribution_name=distribution_name) + if local_source_distribution is not None: + return local_source_distribution + + for candidate_name in dragonfly_distribution_candidates(distribution_name): + distribution = get_distribution(candidate_name) + if distribution is not None: + return { + "distribution": distribution, + "distribution_name": candidate_name, + "source_url": "", + "warning": "", + } + + if distribution_name not in (None, "", "auto"): + raise WheelResolutionError( + "Installed distribution '{0}' could not be found".format(distribution_name) + ) + + raise WheelResolutionError( + "Installed distributions '{0}' and '{1}' could not be found".format( + DEFAULT_DRAGONFLY_DISTRIBUTION, + LOCAL_DRAGONFLY_DISTRIBUTION, + ) + ) + + +def discover_kaldi_requirement(distribution_name=None): + distribution_info = resolve_dragonfly_distribution(distribution_name=distribution_name) + distribution = distribution_info["distribution"] + selected_distribution_name = distribution_info["distribution_name"] + + for requirement_text in distribution.requires or []: + requirement = Requirement(requirement_text) + if normalize_distribution_name(requirement.name) == KALDI_PACKAGE_NAME: + return { + "distribution_name": selected_distribution_name, + "kaldi_version_spec": str(requirement.specifier), + "source_url": distribution_info["source_url"], + "warning": distribution_info["warning"], + } + + raise WheelResolutionError( + "Installed distribution '{0}' does not declare a '{1}' dependency".format( + selected_distribution_name, + KALDI_PACKAGE_NAME, + ) + ) + + +def discover_kaldi_requirement_specifier(distribution_name=None): + return discover_kaldi_requirement(distribution_name=distribution_name)["kaldi_version_spec"] + + +def normalize_distribution_name(distribution_name): + return (distribution_name or "").replace("_", "-").lower() + + +def select_compatible_wheel(assets, system_name=None, machine_name=None, version_info=None, version_specifier=None): + system_name = normalize_system(system_name or platform.system()) + machine_name = normalize_machine(machine_name or platform.machine()) + version_info = version_info or sys.version_info + version_specifier = normalize_kaldi_requirement_specifier(version_specifier) + + candidates = [] + for asset in assets: + parsed = parse_wheel_filename(asset.get("name", "")) + if not parsed: + continue + if parsed["version"] not in version_specifier: + continue + if not platform_tags_match(parsed["plat_tags"], system_name, machine_name): + continue + if not python_tags_match(parsed["py_tags"], version_info): + continue + candidate = dict(asset) + candidate["_parsed_version"] = parsed["version"] + candidates.append((parsed["version"], python_tag_priority(parsed["py_tags"], version_info), candidate)) + + if not candidates: + message = "No compatible kaldi-active-grammar wheel found for {0} {1} Python {2}.{3}".format( + system_name, + machine_name, + version_info[0], + version_info[1], + ) + if version_specifier: + message += " matching '{0}'".format(version_specifier) + raise WheelResolutionError(message) + + candidates.sort(key=lambda item: (item[0], item[1], item[2].get("name", "")), reverse=True) + selected = candidates[0][2] + selected.pop("_parsed_version", None) + return selected + + +def fetch_releases(releases_url=RELEASES_URL, urlopen_fn=urlopen): + request = Request( + releases_url, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "Caster Kaldi Installer", + }, + ) + with urlopen_fn(request, timeout=30) as response: + return normalize_releases_payload(json.load(response)) + + +def release_assets(releases): + flattened_assets = [] + for release in releases: + if release.get("draft") or release.get("prerelease"): + continue + for asset in release.get("assets", []): + flattened_asset = dict(asset) + flattened_asset["tag_name"] = release.get("tag_name", "") + flattened_assets.append(flattened_asset) + return flattened_assets + + +def resolve_latest_wheel( + releases_url=RELEASES_URL, + system_name=None, + machine_name=None, + version_info=None, + version_specifier=None, + distribution_name="auto", + urlopen_fn=urlopen, +): + requirement_info = {"distribution_name": "", "source_url": "", "warning": ""} + if version_specifier is None: + requirement_info = discover_kaldi_requirement(distribution_name=distribution_name) + version_specifier = requirement_info["kaldi_version_spec"] + + releases = fetch_releases(releases_url=releases_url, urlopen_fn=urlopen_fn) + asset = select_compatible_wheel( + release_assets(releases), + system_name=system_name, + machine_name=machine_name, + version_info=version_info, + version_specifier=version_specifier, + ) + result = { + "tag_name": asset.get("tag_name", ""), + "asset_name": asset.get("name", ""), + "browser_download_url": asset.get("browser_download_url", ""), + "kaldi_version_spec": str(version_specifier), + } + if requirement_info["distribution_name"]: + result["kaldi_requirement_distribution"] = requirement_info["distribution_name"] + if requirement_info["source_url"]: + result["local_dragonfly_source_url"] = requirement_info["source_url"] + if requirement_info["warning"]: + result["kaldi_requirement_warning"] = requirement_info["warning"] + return result + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description="Resolve the latest compatible kaldi-active-grammar wheel.") + parser.add_argument("--release-url", "--releases-url", dest="releases_url", default=RELEASES_URL) + parser.add_argument("--system", default=platform.system()) + parser.add_argument("--machine", default=platform.machine()) + parser.add_argument("--python-major", type=int, default=sys.version_info.major) + parser.add_argument("--python-minor", type=int, default=sys.version_info.minor) + parser.add_argument("--kaldi-version-spec", default=None) + parser.add_argument("--dragonfly-distribution", default="auto") + parser.add_argument("--detect-local-source-dragonfly", action="store_true") + return parser.parse_args(argv) + + +def emit_requirement_info(requirement_info): + if requirement_info.get("distribution_name"): + print("kaldi_requirement_distribution={0}".format(requirement_info["distribution_name"])) + if requirement_info.get("source_url"): + print("local_dragonfly_source_url={0}".format(requirement_info["source_url"])) + if requirement_info.get("warning"): + print("kaldi_requirement_warning={0}".format(requirement_info["warning"])) + + +def main(argv=None): + args = parse_args(argv) + if args.detect_local_source_dragonfly: + distribution_info = detect_local_source_dragonfly_distribution( + distribution_name=args.dragonfly_distribution + ) + if distribution_info is not None: + print("local_dragonfly_distribution={0}".format(distribution_info["distribution_name"])) + print("local_dragonfly_source_url={0}".format(distribution_info["source_url"])) + return 0 + + requirement_info = None + try: + result = resolve_latest_wheel( + releases_url=args.releases_url, + system_name=args.system, + machine_name=args.machine, + version_info=(args.python_major, args.python_minor), + version_specifier=args.kaldi_version_spec, + distribution_name=args.dragonfly_distribution, + ) + except Exception as exc: + if args.kaldi_version_spec is None: + try: + requirement_info = discover_kaldi_requirement(distribution_name=args.dragonfly_distribution) + except Exception: # pragma: no cover - best effort metadata for installer fallback decisions + requirement_info = None + if requirement_info: + emit_requirement_info(requirement_info) + print(str(exc), file=sys.stderr) + return 1 + + print("tag_name={0}".format(result["tag_name"])) + print("asset_name={0}".format(result["asset_name"])) + print("browser_download_url={0}".format(result["browser_download_url"])) + print("kaldi_version_spec={0}".format(result["kaldi_version_spec"])) + emit_requirement_info( + { + "distribution_name": result.get("kaldi_requirement_distribution", ""), + "source_url": result.get("local_dragonfly_source_url", ""), + "warning": result.get("kaldi_requirement_warning", ""), + } + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/format_hook.py b/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/format_hook.py index 776c2e038..5b6c3c70a 100644 --- a/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/format_hook.py +++ b/castervoice/lib/merge/ccrmerging2/hooks/standard_hooks/format_hook.py @@ -28,12 +28,9 @@ def __init__(self): def get_pronunciation(self): return "formatting" - def run(self, event): - if event.active: - _apply_format(event.rule_class_name) - else: - textformat.format.clear_text_format() - textformat.secondary_format.clear_text_format() + def run(self, event_content): + pronunciation = event_content["pronunciation"] + _apply_format(pronunciation) def get_hook(): diff --git a/castervoice/lib/navigation.py b/castervoice/lib/navigation.py index f55e9296e..1d834986b 100644 --- a/castervoice/lib/navigation.py +++ b/castervoice/lib/navigation.py @@ -73,25 +73,25 @@ def mouse_alternates(cls, mode, monitor=1, rough=True): ls.scan(bbox, rough) tscan = ls.get_update() args = [ - settings.settings(["paths", "PYTHONW"]), + settings.runtime_hidden_console_binary(), settings.settings(["paths", "LEGION_PATH"]), "-t", tscan[0], "-m", str(monitor) ] elif mode == "rainbow": args = [ - settings.settings(["paths", "PYTHONW"]), + settings.runtime_hidden_console_binary(), settings.settings(["paths", "RAINBOW_PATH"]), "-g", "r", "-m", str(monitor) ] elif mode == "douglas": args = [ - settings.settings(["paths", "PYTHONW"]), + settings.runtime_hidden_console_binary(), settings.settings(["paths", "DOUGLAS_PATH"]), "-g", "d", "-m", str(monitor) ] elif mode == "sudoku": args = [ - settings.settings(["paths", "PYTHONW"]), + settings.runtime_hidden_console_binary(), settings.settings(["paths", "SUDOKU_PATH"]), "-g", "s", "-m", str(monitor) ] diff --git a/castervoice/lib/qt.py b/castervoice/lib/qt.py index f4e735647..0f0ded36b 100644 --- a/castervoice/lib/qt.py +++ b/castervoice/lib/qt.py @@ -8,9 +8,17 @@ 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, rerun the Caster installer or install with: " + "uv pip install --python .venv\\Scripts\\python.exe \"PySide6>=6.6\"" + ) from pyside6_err def qt_attr(root, *paths): diff --git a/castervoice/lib/settings.py b/castervoice/lib/settings.py index 186f071a0..6c83e6995 100644 --- a/castervoice/lib/settings.py +++ b/castervoice/lib/settings.py @@ -42,26 +42,37 @@ WSR = False _BASE_PATH = None _USER_DIR = None +_USER_DIR_REPORTED = False _SETTINGS_PATH = None +def _hidden_console_binary_for(executable): + executable_path = Path(executable) + if sys.platform != "win32": + return str(executable_path) + if executable_path.name.lower() == "pythonw.exe": + return str(executable_path) + hidden_console_binary = executable_path.with_name("pythonw.exe") + if hidden_console_binary.is_file(): + return str(hidden_console_binary) + return str(executable_path) + + def _get_platform_information(): """Return a dictionary containing platform-specific information.""" import sysconfig system_information = {"platform": sysconfig.get_platform()} system_information.update({"python version": sys.version_info}) binary_path = str(Path(sys.exec_prefix).joinpath(sys.exec_prefix).joinpath("bin")) - hidden_console_binary = str(Path(sys.executable)) main_binary = str(Path(sys.executable)) if sys.platform == "win32": if sys.prefix == sys.base_prefix: main_binary = str(Path(sys.exec_prefix).joinpath("python.exe")) - hidden_console_binary = str(Path(sys.exec_prefix).joinpath("pythonw.exe")) else: # Virtual environment detected # TODO: MacOS and Linux? main_binary = str(Path(sys.prefix) / "Scripts" / "python.exe") - hidden_console_binary = str(Path(sys.prefix) / "Scripts" / "pythonw.exe") + hidden_console_binary = _hidden_console_binary_for(main_binary) system_information.update({"binary path": binary_path}) system_information.update({"main binary": main_binary}) system_information.update({"hidden console binary": hidden_console_binary}) @@ -73,6 +84,35 @@ 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: + runtime_binary = SYSTEM_INFORMATION.get("hidden console binary", "") + if runtime_binary and os.path.isfile(runtime_binary): + return runtime_binary + configured_binary = settings(["paths", "PYTHONW"], "") + if configured_binary and os.path.isfile(configured_binary): + return configured_binary + return _hidden_console_binary_for(sys.executable) + + def _validate_engine_path(): ''' Validates path 'Engine Path' in settings.toml @@ -471,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. @@ -488,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/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/Getting_Started/Choose_Recognition_Engine.md b/docs/Getting_Started/Choose_Recognition_Engine.md index 1e060af23..d790a15f8 100644 --- a/docs/Getting_Started/Choose_Recognition_Engine.md +++ b/docs/Getting_Started/Choose_Recognition_Engine.md @@ -58,6 +58,10 @@ Daanzu's [Kaldi](https://dragonfly2.readthedocs.io/en/latest/kaldi_engine.html) - Many other highlights can be found in Daanzu's [Kaldi Documentation](https://dragonfly2.readthedocs.io/en/latest/kaldi_engine.html) +- Windows Kaldi setup uses `Install_Caster_Kaldi.bat` from the project root. See [Windows Kaldi install](../Installation/Windows/Kaldi.md). +- `Install_Caster_Kaldi.bat` creates or updates the repo-local `.venv` with uv-managed CPython `3.12`, and `Run_Caster_Kaldi.bat` always launches with `.venv\Scripts\python.exe`. +- For Qt-based UI features (HUD/settings), use the installer-created uv-managed Windows x64 `.venv`. + Disadvantages - Does not have a GUI for editing vocabulary (User Lexicon) but can be easily edited through txt file. @@ -71,6 +75,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). +- `Install_Caster_WSR.bat` creates or updates the repo-local `.venv` with uv-managed CPython `3.12`, and `Run_Caster_WSR.bat` always launches with `.venv\Scripts\python.exe`. +- For Qt-based UI features (HUD/settings), use the installer-created uv-managed Windows x64 `.venv`. - 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/Kaldi.md b/docs/Installation/Windows/Kaldi.md index f1051e73a..29112bbd9 100644 --- a/docs/Installation/Windows/Kaldi.md +++ b/docs/Installation/Windows/Kaldi.md @@ -1,52 +1,101 @@ # Kaldi - Classic Install -Caster currently supports Kaldi on Microsoft Windows 7 through Windows 10. Consider supporting the author [daanzu](https://github.com/sponsors/daanzu) if you use his engine full-time. +Caster currently supports Kaldi on Microsoft Windows 10 through Windows 11. Consider supporting the author [daanzu](https://github.com/sponsors/daanzu) if you use his engine full-time. -## 1. Python +## 1. Prerequisites -- First Download and install [Python 3](https://www.python.org/downloads/release/python-3810/) listed as `Windows installer (64-bit)`. - - 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 3 folder to the list of Path values. - - Be sure the `wheel` package is installed. It can be installed with `pip install wheel` on a command line. You may need to update pip by running `pip install --upgrade pip` first. +- Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and ensure `uv` is available on `PATH`. +- `Install_Caster_Kaldi.bat` creates or updates the repo-local `.venv` with uv-managed CPython `3.12`. + - If uv has not installed Python `3.12` yet, run `uv python install 3.12` and rerun the installer. + - The Kaldi installer is validated for 64-bit Windows Python in that uv-managed `.venv`. ## 2. Caster 1. Download Caster from the [master branch](https://github.com/dictation-toolbox/Caster/archive/master.zip). -2. Open up the zip file downloaded -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_Kaldi.bat` to install prerequisite dependencies and set up Kaldi. - -## 3. Set up Kaldi Model - -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` +2. Open the downloaded zip file. +3. Copy the contents of the `Caster-master` folder. You can put it anywhere, but `%USERPROFILE%\Documents\Caster` is common. +4. *Optional step* for Caster's `Legion` MouseGrid 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) This should not be needed if Windows 10 or Windows 11 is up to date. +5. Click `Install_Caster_Kaldi.bat` to install prerequisite Caster dependencies for Kaldi. + - The installer creates or updates `.\.venv` with uv-managed CPython `3.12` and installs dependencies into that local virtual environment. + - The installer tries to resolve the latest `kaldi-active-grammar` GitHub wheel compatible with the Dragonfly distribution installed in `.\.venv`. + - If a local `dragonfly` source-directory install is already present in `.\.venv`, the installer preserves it, uses its Kaldi compatibility metadata, and does not replace it with `dragonfly2`. + - On the default `dragonfly2` path, the installer falls back to `dragonfly2[kaldi]` if GitHub lookup or wheel installation fails. + - If a preserved local `dragonfly` source install does not have a compatible Kaldi path, the installer stops with an error instead of overwriting it. + - The installer can optionally download and extract a Kaldi model into `.\kaldi_model` for you. + +## 3. Set Up Kaldi Model + +1. When prompted by `Install_Caster_Kaldi.bat`, choose whether to download a Kaldi model now. + - The guided installer currently offers: + - `medium` as the recommended balanced choice + - `small` for a smaller download + - `big` for the largest model +2. If you skip the guided download, download your preferred Kaldi model from [kaldi-active-grammar/releases](https://github.com/daanzu/kaldi-active-grammar/releases). + - Current model downloads are linked from the upstream `docs/models.md` page and may come from an older release tag than the latest engine wheel release. +3. Extract `kaldi_model_.zip` to `%USERPROFILE%\Documents\Caster`. ## 4. Launch Caster (Kaldi) for Classic Install -1. Go to `%USERPROFILE%\Documents\Caster` -2. Double-click on `Run_Caster_Kaldi.bat` +1. Go to `%USERPROFILE%\Documents\Caster`. +2. Double-click `Run_Caster_Kaldi.bat`. -**Note:** Kaldi is a 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. +- `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 -1. Backup `%USERPROFILE%\Documents\Caster` -2. Delete `%USERPROFILE%\Documents\Caster` -3. Repeat Steps `1. - 5.` within the Caster install section +1. Backup `%USERPROFILE%\Documents\Caster`. +2. Delete `%USERPROFILE%\Documents\Caster`. +3. Repeat Steps `1. - 5.` within the Caster install section. ------ -**Troubleshooting Kaldi** +### Troubleshooting Kaldi + +- 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 an error that uv-managed Python `3.12` could not be created for `.\.venv`. + + > fix: run `uv python install 3.12`, then rerun `Install_Caster_Kaldi.bat`. + +- Receive a notice that the latest GitHub Kaldi wheel could not be resolved or installed. + + > fix: if the installer is managing `dragonfly2`, it will automatically fall back to `dragonfly2[kaldi]`. If it detected a local `dragonfly` source install in `.\.venv`, it will stop instead so that install is not overwritten. + +- Skip the model download during install, or the guided model download fails. + + > fix: rerun `Install_Caster_Kaldi.bat` to use the guided model installer again, or download a model manually from the upstream releases page and extract it to `.\kaldi_model`. + +- Receive a message that Qt dependencies were skipped or PySide6 failed to install. + + > This happens on unsupported architectures or when no compatible PySide6 wheel is available for the installer-created uv-managed Windows x64 `.venv`. Core Kaldi grammar functionality can still run, but HUD/settings-window features requiring Qt are unavailable. + +- Receive an error about a missing or invalid Kaldi runtime interpreter in `Run_Caster_Kaldi.bat`. + + > 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. -- No commonly reported issues yet. + > 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 OS +- Kaldi outputs a lot of text to the Caster status window on Windows. - [Kaldi mitigation for keyboard action processing bug via debug mode](https://github.com/dictation-toolbox/Caster/issues/799) diff --git a/docs/Installation/Windows/Windows_Speech_Recognition.md b/docs/Installation/Windows/Windows_Speech_Recognition.md index 58487b018..ced3cafec 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`. - - 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. +- Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and ensure `uv` is available on `PATH`. +- `Install_Caster_WSR.bat` creates or updates the repo-local `.venv` with uv-managed CPython `3.12`. + - If uv has not installed Python `3.12` yet, run `uv python install 3.12` and rerun the installer. + - The WSR installer is validated for 64-bit Windows Python in that uv-managed `.venv`. ## 2. Caster @@ -14,13 +16,15 @@ 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 creates or updates `.\.venv` with uv-managed CPython `3.12` and installs dependencies into that local virtual environment. + - If a local `dragonfly` source-directory install is already present in `.\.venv`, the installer preserves it instead of replacing it with `dragonfly2`. ## 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 +38,22 @@ 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 an error that uv-managed Python `3.12` could not be created for `.\.venv`. + + > fix: run `uv python install 3.12`, then rerun `Install_Caster_WSR.bat`. + +- Receive a message that Qt dependencies were skipped. + + > This happens on unsupported architectures or when no compatible PySide6 wheel is available for the installer-created uv-managed Windows x64 `.venv`. Core WSR grammar functionality can still run, but HUD/settings-window features requiring Qt are unavailable. + +- Receive an error about a missing or invalid WSR runtime interpreter in `Run_Caster_WSR.bat`. + + > fix: rerun `Install_Caster_WSR.bat` to recreate `.\.venv` with uv-managed Python and restore `.\.venv\Scripts\python.exe`. + - 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-windows-installer.txt b/requirements-windows-installer.txt new file mode 100644 index 000000000..8c413a263 --- /dev/null +++ b/requirements-windows-installer.txt @@ -0,0 +1,9 @@ +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/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/__init__.py b/tests/__init__.py index e69de29bb..5c412ad2c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +from castervoice.lib.inspect_compat import ensure_getargspec + + +ensure_getargspec() diff --git a/tests/conftest.py b/tests/conftest.py index 820102a63..573fc5daa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,7 @@ +from castervoice.lib.inspect_compat import ensure_getargspec + +ensure_getargspec() + from dragonfly import get_engine get_engine("text") diff --git a/tests/lib/ctrl/test_EngineModesManager.py b/tests/lib/ctrl/test_EngineModesManager.py index 8b6c0b3eb..e3af18f2f 100644 --- a/tests/lib/ctrl/test_EngineModesManager.py +++ b/tests/lib/ctrl/test_EngineModesManager.py @@ -1,4 +1,5 @@ from unittest import TestCase +from unittest import mock from castervoice.lib.ctrl.mgr.engine_manager import EngineModesManager @@ -10,6 +11,14 @@ def set_mode(self, mode, modetype): class TestEngineModesManager(TestCase): def setUp(self): + engine = mock.Mock() + engine.name = "text" + engine_patcher = mock.patch( + "castervoice.lib.ctrl.mgr.engine_manager.get_current_engine", + return_value=engine, + ) + engine_patcher.start() + self.addCleanup(engine_patcher.stop) self._manager = EngineModesManager(mockExclusiveManager()) def test_set_engine_mode(self): diff --git a/tests/lib/ctrl/test_dependencies.py b/tests/lib/ctrl/test_dependencies.py new file mode 100644 index 000000000..aaf7ffaf4 --- /dev/null +++ b/tests/lib/ctrl/test_dependencies.py @@ -0,0 +1,150 @@ +import types +import unittest +from importlib.metadata import PackageNotFoundError +from unittest.mock import mock_open, patch + +from castervoice.lib.ctrl import dependencies + + +class TestDependencies(unittest.TestCase): + + def test_dep_missing_passes_full_requirement_spec_to_checker(self): + requirements = 'PySide2>=5.14;platform_system!="Windows"\n' + with patch("builtins.open", mock_open(read_data=requirements)): + with patch("castervoice.lib.ctrl.dependencies._requirement_is_installed", return_value=True) as installed_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() + + installed_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._requirement_is_installed", return_value=False): + 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() + + 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._requirement_is_installed", side_effect=[False, False]): + 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() + + 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._requirement_is_installed", return_value=True) as installed_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() + + installed_mock.assert_called_once_with("six") + out_mock.assert_not_called() + sleep_mock.assert_not_called() + + def test_requirement_is_installed_skips_requirements_with_false_markers(self): + requirement = 'PySide2>=5.14; platform_system == "Darwin"' + with patch("castervoice.lib.ctrl.dependencies.default_environment", return_value={"platform_system": "Windows"}): + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution") as distribution_mock: + self.assertTrue(dependencies._requirement_is_installed(requirement)) + + distribution_mock.assert_not_called() + + def test_requirement_is_installed_rejects_invalid_installed_version(self): + installed = types.SimpleNamespace(version="not_a_pep440_version", requires=[]) + with patch("castervoice.lib.ctrl.dependencies._installed_distribution", return_value=installed): + self.assertFalse(dependencies._requirement_is_installed("example_pkg>=1.0")) + + def test_requirement_is_installed_detects_missing_plain_dependency_chain(self): + def fake_distribution(name): + if name == "dragonfly2": + return types.SimpleNamespace( + version="0.34.0", + requires=["comtypes>=1.1"], + ) + raise PackageNotFoundError(name) + + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution", side_effect=fake_distribution): + self.assertFalse(dependencies._requirement_is_installed("dragonfly2>=0.34.0")) + + def test_requirement_is_installed_accepts_installed_plain_dependency_chain(self): + def fake_distribution(name): + if name == "dragonfly2": + return types.SimpleNamespace( + version="0.34.0", + requires=["comtypes>=1.1"], + ) + if name == "comtypes": + return types.SimpleNamespace(version="1.2.0", requires=[]) + raise PackageNotFoundError(name) + + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution", side_effect=fake_distribution): + self.assertTrue(dependencies._requirement_is_installed("dragonfly2>=0.34.0")) + + def test_requirement_is_installed_detects_missing_requested_extra_dependencies(self): + def fake_distribution(name): + if name == "dragonfly2": + return types.SimpleNamespace( + version="0.34.0", + requires=['kaldi-active-grammar; extra == "kaldi"'], + ) + raise PackageNotFoundError(name) + + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution", side_effect=fake_distribution): + self.assertFalse(dependencies._requirement_is_installed("dragonfly2[kaldi]>=0.34.0")) + + def test_requirement_is_installed_accepts_installed_requested_extra_dependencies(self): + 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 PackageNotFoundError(name) + + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution", side_effect=fake_distribution): + self.assertTrue(dependencies._requirement_is_installed("dragonfly2[kaldi]>=0.34.0")) + + def test_requirement_is_installed_accepts_dragonfly_alias_for_dragonfly2(self): + def fake_distribution(name): + if name == "dragonfly2": + raise PackageNotFoundError(name) + if name == "dragonfly": + return types.SimpleNamespace(version="1.0.0", requires=[]) + raise PackageNotFoundError(name) + + with patch("castervoice.lib.ctrl.dependencies.metadata.distribution", side_effect=fake_distribution): + self.assertTrue(dependencies._requirement_is_installed("dragonfly2>=0.34.0")) + + def test_dep_min_version_accepts_dragonfly_alias_for_dragonfly2(self): + with patch( + "castervoice.lib.ctrl.dependencies._installed_distribution", + return_value=types.SimpleNamespace(version="1.0.0"), + ): + with patch("castervoice.lib.ctrl.dependencies.printer.out") as out_mock: + dependencies.dep_min_version() + + out_mock.assert_not_called() diff --git a/tests/lib/test_inspect_compat.py b/tests/lib/test_inspect_compat.py new file mode 100644 index 000000000..c35c0a657 --- /dev/null +++ b/tests/lib/test_inspect_compat.py @@ -0,0 +1,27 @@ +import importlib +import inspect +import types +import unittest + +from castervoice.lib.inspect_compat import ensure_getargspec + + +class TestInspectCompat(unittest.TestCase): + + def test_ensure_getargspec_restores_legacy_signature_shape(self): + fake_inspect = types.SimpleNamespace(getfullargspec=inspect.getfullargspec) + + def sample(alpha, beta=2, *args, **kwargs): + return alpha, beta, args, kwargs + + ensure_getargspec(fake_inspect) + argspec = fake_inspect.getargspec(sample) + + self.assertEqual(["alpha", "beta"], argspec.args) + self.assertEqual("args", argspec.varargs) + self.assertEqual("kwargs", argspec.keywords) + self.assertEqual((2,), argspec.defaults) + + def test_contexts_import_under_python_312_style_inspect(self): + module = importlib.import_module("castervoice.lib.contexts") + self.assertTrue(hasattr(module, "WINDOWS_CONTEXT")) diff --git a/tests/lib/test_kaldi_model.py b/tests/lib/test_kaldi_model.py new file mode 100644 index 000000000..6af994161 --- /dev/null +++ b/tests/lib/test_kaldi_model.py @@ -0,0 +1,150 @@ +import io +import json +import shutil +import unittest +import uuid +import zipfile +from pathlib import Path +from unittest.mock import patch + +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 + + +class _FakeResponse: + + def __init__(self, payload, headers=None): + self._buffer = io.BytesIO(payload) + self.headers = headers or {} + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + return self._buffer.read(size) + + +class _FixedTempDir: + + def __init__(self, path): + self.path = Path(path) + + def __enter__(self): + if self.path.exists(): + shutil.rmtree(self.path) + self.path.mkdir(parents=True) + return str(self.path) + + def __exit__(self, exc_type, exc, tb): + if self.path.exists(): + shutil.rmtree(self.path) + return False + + +class TestKaldiModel(unittest.TestCase): + + def test_select_latest_models_by_tier_uses_first_current_entry(self): + markdown = """ +* [kaldi_model_daanzu_20211030-mediumlm](https://example.invalid/v3.0.0/kaldi_model_daanzu_20211030-mediumlm.zip) (651 MB) +* [kaldi_model_daanzu_20211030-smalllm](https://example.invalid/v3.0.0/kaldi_model_daanzu_20211030-smalllm.zip) (400 MB) +* [kaldi_model_daanzu_20211030-biglm](https://example.invalid/v3.0.0/kaldi_model_daanzu_20211030-biglm.zip) (1.05 GB) +* [kaldi_model_daanzu_20200905_1ep-mediumlm](https://example.invalid/v1.8.0/kaldi_model_daanzu_20200905_1ep-mediumlm.zip) (651 MB) +""" + + selected = select_latest_models_by_tier(parse_models_markdown(markdown)) + + self.assertEqual("kaldi_model_daanzu_20211030-mediumlm", selected["medium"]["name"]) + self.assertEqual("kaldi_model_daanzu_20211030-smalllm", selected["small"]["name"]) + self.assertEqual("kaldi_model_daanzu_20211030-biglm", selected["big"]["name"]) + + def test_install_model_archive_preserves_user_lexicon_and_writes_metadata(self): + archive_bytes = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as archive: + archive.writestr("kaldi_model_daanzu_20211030-mediumlm/graph/phones.txt", "phones") + archive.writestr("kaldi_model_daanzu_20211030-mediumlm/conf/model.conf", "conf") + + model = { + "name": "kaldi_model_daanzu_20211030-mediumlm", + "tier": "medium", + "size": "651 MB", + "url": "https://example.invalid/kaldi_model_daanzu_20211030-mediumlm.zip", + "source_release": "v3.0.0", + } + + tmp_user_root = Path("tmp_user") + repo_root = tmp_user_root / ("kaldi_model_test_repo_" + uuid.uuid4().hex) + repo_root.mkdir(parents=True) + try: + target_dir = repo_root / MODEL_DIR_NAME + target_dir.mkdir() + (target_dir / USER_LEXICON_NAME).write_text("keep-me", encoding="utf-8") + temp_work_dir = repo_root / "installer-temp" + + with patch("castervoice.lib.kaldi_model.tempfile.TemporaryDirectory", return_value=_FixedTempDir(temp_work_dir)): + installed_dir = install_model_archive( + model, + repo_root, + urlopen_fn=lambda request, timeout=60: _FakeResponse(archive_bytes.getvalue()), + ) + + self.assertEqual(target_dir, installed_dir) + self.assertEqual("keep-me", (target_dir / USER_LEXICON_NAME).read_text(encoding="utf-8")) + self.assertTrue((target_dir / "graph" / "phones.txt").is_file()) + metadata = json.loads((target_dir / MODEL_METADATA_NAME).read_text(encoding="utf-8")) + self.assertEqual("kaldi_model_daanzu_20211030-mediumlm", metadata["model_name"]) + self.assertEqual("medium", metadata["tier"]) + finally: + if repo_root.exists(): + shutil.rmtree(repo_root, ignore_errors=True) + try: + 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() diff --git a/tests/lib/test_kaldi_wheel.py b/tests/lib/test_kaldi_wheel.py new file mode 100644 index 000000000..cde01a811 --- /dev/null +++ b/tests/lib/test_kaldi_wheel.py @@ -0,0 +1,373 @@ +import contextlib +import io +import json +import unittest +from importlib.metadata import PackageNotFoundError +from unittest import mock + +from castervoice.lib.kaldi_wheel import discover_kaldi_requirement +from castervoice.lib.kaldi_wheel import discover_kaldi_requirement_specifier +from castervoice.lib.kaldi_wheel import main +from castervoice.lib.kaldi_wheel import WheelResolutionError +from castervoice.lib.kaldi_wheel import resolve_latest_wheel +from castervoice.lib.kaldi_wheel import select_compatible_wheel + + +class _FakeResponse: + + def __init__(self, payload): + self._buffer = io.BytesIO(json.dumps(payload).encode("utf-8")) + + def __enter__(self): + return self._buffer + + def __exit__(self, exc_type, exc, tb): + return False + + +class TestKaldiWheel(unittest.TestCase): + + def test_discover_kaldi_requirement_specifier_reads_dragonfly_metadata(self): + distribution = mock.Mock() + distribution.requires = [ + 'kaldi-active-grammar (~=3.1.0) ; extra == "kaldi"', + "sounddevice (==0.3.*) ; extra == 'kaldi'", + ] + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", return_value=distribution): + self.assertEqual("~=3.1.0", discover_kaldi_requirement_specifier()) + + def test_discover_kaldi_requirement_prefers_local_dragonfly_source_install(self): + local_distribution = mock.Mock() + local_distribution.requires = [ + 'kaldi-active-grammar (~=3.2.0) ; extra == "kaldi"', + ] + local_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", "dir_info": {"editable": True}} + ) + + packaged_distribution = mock.Mock() + packaged_distribution.requires = [ + 'kaldi-active-grammar (~=3.1.0) ; extra == "kaldi"', + ] + packaged_distribution.read_text.return_value = None + + def fake_distribution(name): + if name == "dragonfly": + return local_distribution + if name == "dragonfly2": + return packaged_distribution + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + result = discover_kaldi_requirement() + + self.assertEqual("dragonfly", result["distribution_name"]) + self.assertEqual("~=3.2.0", result["kaldi_version_spec"]) + self.assertEqual("file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", result["source_url"]) + self.assertIn("Local dragonfly install detected", result["warning"]) + + def test_discover_kaldi_requirement_prefers_local_dragonfly2_source_install(self): + default_distribution = mock.Mock() + default_distribution.requires = [ + 'kaldi-active-grammar (~=3.4.0) ; extra == "kaldi"', + ] + default_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly2", "dir_info": {"editable": True}} + ) + + legacy_distribution = mock.Mock() + legacy_distribution.requires = [ + 'kaldi-active-grammar (~=3.2.0) ; extra == "kaldi"', + ] + legacy_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", "dir_info": {"editable": True}} + ) + + def fake_distribution(name): + if name == "dragonfly2": + return default_distribution + if name == "dragonfly": + return legacy_distribution + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution) as lookup: + result = discover_kaldi_requirement() + + self.assertEqual("dragonfly2", result["distribution_name"]) + self.assertEqual("~=3.4.0", result["kaldi_version_spec"]) + self.assertEqual("file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly2", result["source_url"]) + self.assertEqual("", result["warning"]) + self.assertEqual([mock.call("dragonfly2")], lookup.call_args_list) + + def test_discover_kaldi_requirement_does_not_treat_local_dragonfly_archive_as_source_install(self): + local_distribution = mock.Mock() + local_distribution.requires = [ + 'kaldi-active-grammar (~=3.2.0) ; extra == "kaldi"', + ] + local_distribution.read_text.return_value = json.dumps( + {"url": "file:///C:/tmp/dragonfly-1.0.0.whl", "archive_info": {"hash": "sha256=abc"}} + ) + + packaged_distribution = mock.Mock() + packaged_distribution.requires = [ + 'kaldi-active-grammar (~=3.1.0) ; extra == "kaldi"', + ] + packaged_distribution.read_text.return_value = None + + def fake_distribution(name): + if name == "dragonfly": + return local_distribution + if name == "dragonfly2": + return packaged_distribution + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + result = discover_kaldi_requirement() + + self.assertEqual("dragonfly2", result["distribution_name"]) + self.assertEqual("~=3.1.0", result["kaldi_version_spec"]) + self.assertEqual("", result["source_url"]) + self.assertEqual("", result["warning"]) + + def test_select_compatible_wheel_picks_windows_asset(self): + assets = [ + {"name": "kaldi_active_grammar-3.2.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, + {"name": "kaldi_active_grammar-3.2.0-py3-none-macosx_10_9_x86_64.whl"}, + {"name": "kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl"}, + ] + + asset = select_compatible_wheel( + assets, + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + ) + + self.assertEqual("kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", asset["name"]) + + def test_select_compatible_wheel_prefers_exact_python_tag(self): + assets = [ + {"name": "kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl"}, + {"name": "kaldi_active_grammar-3.2.0-cp312-none-win_amd64.whl"}, + ] + + asset = select_compatible_wheel( + assets, + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + ) + + self.assertEqual("kaldi_active_grammar-3.2.0-cp312-none-win_amd64.whl", asset["name"]) + + def test_select_compatible_wheel_raises_without_supported_asset(self): + assets = [ + {"name": "kaldi_active_grammar-3.2.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"}, + ] + + with self.assertRaises(WheelResolutionError): + select_compatible_wheel( + assets, + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + ) + + def test_resolve_latest_wheel_returns_release_metadata(self): + release = { + "tag_name": "v3.2.0", + "assets": [ + { + "name": "kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + "browser_download_url": "https://example.invalid/kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + }, + ], + } + + result = resolve_latest_wheel( + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + version_specifier="~=3.2.0", + urlopen_fn=lambda request, timeout=30: _FakeResponse(release), + ) + + self.assertEqual("v3.2.0", result["tag_name"]) + self.assertEqual("kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", result["asset_name"]) + self.assertEqual( + "https://example.invalid/kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + result["browser_download_url"], + ) + self.assertEqual("~=3.2.0", result["kaldi_version_spec"]) + + def test_resolve_latest_wheel_returns_local_dragonfly_warning_metadata(self): + release = { + "tag_name": "v3.2.0", + "assets": [ + { + "name": "kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + "browser_download_url": "https://example.invalid/kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + }, + ], + } + local_distribution = mock.Mock() + local_distribution.requires = ['kaldi-active-grammar (~=3.2.0) ; extra == "kaldi"'] + local_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", "dir_info": {"editable": True}} + ) + + def fake_distribution(name): + if name == "dragonfly": + return local_distribution + if name == "dragonfly2": + raise PackageNotFoundError + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + result = resolve_latest_wheel( + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + urlopen_fn=lambda request, timeout=30: _FakeResponse(release), + ) + + self.assertEqual("dragonfly", result["kaldi_requirement_distribution"]) + self.assertEqual("file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", result["local_dragonfly_source_url"]) + self.assertIn("Local dragonfly install detected", result["kaldi_requirement_warning"]) + + def test_resolve_latest_wheel_uses_latest_matching_release(self): + releases = [ + { + "tag_name": "v3.2.0", + "assets": [ + { + "name": "kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + "browser_download_url": "https://example.invalid/kaldi_active_grammar-3.2.0-py3-none-win_amd64.whl", + }, + ], + }, + { + "tag_name": "v3.1.0", + "assets": [ + { + "name": "kaldi_active_grammar-3.1.0-py3-none-win_amd64.whl", + "browser_download_url": "https://example.invalid/kaldi_active_grammar-3.1.0-py3-none-win_amd64.whl", + }, + ], + }, + ] + + result = resolve_latest_wheel( + system_name="Windows", + machine_name="AMD64", + version_info=(3, 12), + version_specifier="~=3.1.0", + urlopen_fn=lambda request, timeout=30: _FakeResponse(releases), + ) + + self.assertEqual("v3.1.0", result["tag_name"]) + self.assertEqual("kaldi_active_grammar-3.1.0-py3-none-win_amd64.whl", result["asset_name"]) + + def test_main_emits_local_dragonfly_metadata_when_resolution_fails(self): + requirement_info = { + "distribution_name": "dragonfly", + "kaldi_version_spec": "~=3.2.0", + "source_url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", + "warning": "Local dragonfly install detected; using its Kaldi compatibility metadata instead of dragonfly2.", + } + stdout_buffer = io.StringIO() + stderr_buffer = io.StringIO() + + with mock.patch( + "castervoice.lib.kaldi_wheel.resolve_latest_wheel", + side_effect=WheelResolutionError("GitHub lookup failed"), + ): + with mock.patch( + "castervoice.lib.kaldi_wheel.discover_kaldi_requirement", + return_value=requirement_info, + ): + with contextlib.redirect_stdout(stdout_buffer): + with contextlib.redirect_stderr(stderr_buffer): + exit_code = main([]) + + self.assertEqual(1, exit_code) + self.assertIn("kaldi_requirement_distribution=dragonfly", stdout_buffer.getvalue()) + self.assertIn( + "local_dragonfly_source_url=file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", + stdout_buffer.getvalue(), + ) + self.assertIn( + "kaldi_requirement_warning=Local dragonfly install detected; using its Kaldi compatibility metadata instead of dragonfly2.", + stdout_buffer.getvalue(), + ) + self.assertIn("GitHub lookup failed", stderr_buffer.getvalue()) + + def test_main_detect_local_source_dragonfly_emits_source_url_for_dragonfly2(self): + local_distribution = mock.Mock() + local_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly2", "dir_info": {"editable": True}} + ) + stdout_buffer = io.StringIO() + + def fake_distribution(name): + if name == "dragonfly2": + return local_distribution + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + with contextlib.redirect_stdout(stdout_buffer): + exit_code = main(["--detect-local-source-dragonfly"]) + + self.assertEqual(0, exit_code) + self.assertEqual( + "local_dragonfly_distribution=dragonfly2\n" + "local_dragonfly_source_url=file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly2\n", + stdout_buffer.getvalue(), + ) + + def test_main_detect_local_source_dragonfly_emits_source_url_for_legacy_dragonfly(self): + local_distribution = mock.Mock() + local_distribution.read_text.return_value = json.dumps( + {"url": "file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly", "dir_info": {"editable": True}} + ) + stdout_buffer = io.StringIO() + + def fake_distribution(name): + if name == "dragonfly2": + raise PackageNotFoundError(name) + if name == "dragonfly": + return local_distribution + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + with contextlib.redirect_stdout(stdout_buffer): + exit_code = main(["--detect-local-source-dragonfly"]) + + self.assertEqual(0, exit_code) + self.assertEqual( + "local_dragonfly_distribution=dragonfly\n" + "local_dragonfly_source_url=file://wsl.localhost/Ubuntu/home/tester/projects/dragonfly\n", + stdout_buffer.getvalue(), + ) + + def test_main_detect_local_source_dragonfly_ignores_local_archive_install(self): + local_distribution = mock.Mock() + local_distribution.read_text.return_value = json.dumps( + {"url": "file:///C:/tmp/dragonfly-1.0.0.whl", "archive_info": {"hash": "sha256=abc"}} + ) + stdout_buffer = io.StringIO() + + def fake_distribution(name): + if name == "dragonfly2": + return local_distribution + if name == "dragonfly": + raise PackageNotFoundError(name) + raise AssertionError("Unexpected distribution lookup: {0}".format(name)) + + with mock.patch("castervoice.lib.kaldi_wheel.metadata.distribution", side_effect=fake_distribution): + with contextlib.redirect_stdout(stdout_buffer): + exit_code = main(["--detect-local-source-dragonfly"]) + + self.assertEqual(0, exit_code) + self.assertEqual("", stdout_buffer.getvalue()) diff --git a/tests/lib/test_settings.py b/tests/lib/test_settings.py new file mode 100644 index 000000000..00f95c693 --- /dev/null +++ b/tests/lib/test_settings.py @@ -0,0 +1,54 @@ +from unittest import TestCase +from unittest.mock import patch + +from castervoice.lib import settings + + +class TestSettings(TestCase): + + def setUp(self): + self.addCleanup(self._reset_settings_state) + self._reset_settings_state() + + def _reset_settings_state(self): + settings.SETTINGS = None + 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): + with patch.object(settings, "_BASE_PATH", "C:/Caster/castervoice"), \ + patch.object(settings, "_USER_DIR", "C:/Users/Main/AppData/Local/caster"), \ + patch.object(settings, "SYSTEM_INFORMATION", {"hidden console binary": "C:/Python/pythonw.exe"}), \ + patch.object(settings, "_validate_engine_path", return_value=""), \ + patch("castervoice.lib.settings.os.path.isfile", return_value=False): + defaults = settings._get_defaults() + + self.assertNotIn("WSR_RUNTIME_PYTHON_PATH", defaults["paths"]) + self.assertNotIn("KALDI_RUNTIME_PYTHON_PATH", defaults["paths"]) + + def test_runtime_hidden_console_binary_prefers_active_runtime(self): + runtime_pythonw = "C:/Caster/.venv/Scripts/pythonw.exe" + settings.SYSTEM_INFORMATION = {"hidden console binary": runtime_pythonw} + settings.SETTINGS = {"paths": {"PYTHONW": "C:/Legacy/pythonw.exe"}} + + 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")