python-voiceio 1.17.0__tar.gz → 1.17.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. {python_voiceio-1.17.0/python_voiceio.egg-info → python_voiceio-1.17.1}/PKG-INFO +1 -1
  2. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/pyproject.toml +1 -1
  3. {python_voiceio-1.17.0 → python_voiceio-1.17.1/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_security_hardening.py +88 -0
  5. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_setup.py +105 -0
  6. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_tts.py +51 -0
  7. python_voiceio-1.17.1/voiceio/__init__.py +1 -0
  8. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/app.py +6 -3
  9. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/__init__.py +4 -4
  10. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/doctor.py +45 -10
  11. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/learn.py +2 -1
  12. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/uninstall.py +6 -1
  13. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/config.py +40 -3
  14. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/faster_whisper/__init__.py +2 -2
  15. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/corrections.py +3 -2
  16. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/errorlog.py +2 -1
  17. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/health.py +25 -11
  18. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hints.py +2 -1
  19. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/history.py +1 -2
  20. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/install.py +2 -1
  21. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/augment.py +2 -2
  22. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/evaluate.py +2 -2
  23. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/importer.py +1 -2
  24. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/store.py +3 -7
  25. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/llm.py +6 -4
  26. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/platform.py +65 -0
  27. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/retention.py +2 -4
  28. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/configfile.py +2 -2
  29. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/noninteractive.py +6 -2
  30. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/steps.py +15 -1
  31. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/system.py +7 -2
  32. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/chain.py +3 -1
  33. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/vocab_stats.py +2 -1
  34. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/vocabulary.py +5 -1
  35. python_voiceio-1.17.0/voiceio/__init__.py +0 -1
  36. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/LICENSE +0 -0
  37. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/README.md +0 -0
  38. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/python_voiceio.egg-info/SOURCES.txt +0 -0
  39. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
  40. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/python_voiceio.egg-info/entry_points.txt +0 -0
  41. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/python_voiceio.egg-info/requires.txt +0 -0
  42. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/python_voiceio.egg-info/top_level.txt +0 -0
  43. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/setup.cfg +0 -0
  44. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_announce.py +0 -0
  45. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_app_wiring.py +0 -0
  46. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_audio_quality.py +0 -0
  47. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_audio_source.py +0 -0
  48. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_backend_probes.py +0 -0
  49. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_cancel.py +0 -0
  50. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_cli.py +0 -0
  51. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_clipboard_read.py +0 -0
  52. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_clipboard_typer.py +0 -0
  53. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_commands.py +0 -0
  54. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_concurrency_lockdown.py +0 -0
  55. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_config.py +0 -0
  56. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_context_terms.py +0 -0
  57. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_corrections.py +0 -0
  58. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_dead_mic.py +0 -0
  59. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_decoders.py +0 -0
  60. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_engine.py +0 -0
  61. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_errorlog.py +0 -0
  62. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_evdev_uaccess.py +0 -0
  63. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_fallback.py +0 -0
  64. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_fcitx_bridge.py +0 -0
  65. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_feedback_stream.py +0 -0
  66. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_focus.py +0 -0
  67. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_gpu_fallback.py +0 -0
  68. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_health.py +0 -0
  69. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_hints.py +0 -0
  70. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_history.py +0 -0
  71. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_hotkey_modes.py +0 -0
  72. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_engine_limits.py +0 -0
  73. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_engine_listener.py +0 -0
  74. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_pending.py +0 -0
  75. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_pids.py +0 -0
  76. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_ping.py +0 -0
  77. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_session.py +0 -0
  78. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_textlimit.py +0 -0
  79. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_ibus_typer.py +0 -0
  80. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_idle_audio.py +0 -0
  81. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_clean.py +0 -0
  82. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_import.py +0 -0
  83. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_language.py +0 -0
  84. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_metrics.py +0 -0
  85. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_pipeline.py +0 -0
  86. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_quiet.py +0 -0
  87. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_resume.py +0 -0
  88. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_review.py +0 -0
  89. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_learn_targets.py +0 -0
  90. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_llm.py +0 -0
  91. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_llm_api.py +0 -0
  92. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_mic_gain.py +0 -0
  93. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_modifier_guard.py +0 -0
  94. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_numbers.py +0 -0
  95. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_paste_last.py +0 -0
  96. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_pipeline.py +0 -0
  97. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_platform.py +0 -0
  98. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_postcorrect.py +0 -0
  99. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_postprocess.py +0 -0
  100. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_prebuffer.py +0 -0
  101. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_presence.py +0 -0
  102. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_prompt.py +0 -0
  103. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_quiet_audio.py +0 -0
  104. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_recorder_integration.py +0 -0
  105. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_report.py +0 -0
  106. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_retention.py +0 -0
  107. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_robustness.py +0 -0
  108. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_server.py +0 -0
  109. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_service.py +0 -0
  110. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_sherpa.py +0 -0
  111. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_spool.py +0 -0
  112. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_streaming.py +0 -0
  113. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_suggest.py +0 -0
  114. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_tokens.py +0 -0
  115. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_transcriber.py +0 -0
  116. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_tray.py +0 -0
  117. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_typer_manager.py +0 -0
  118. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_vad.py +0 -0
  119. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_vocabulary.py +0 -0
  120. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_wordfreq.py +0 -0
  121. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/tests/test_worker_decode.py +0 -0
  122. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/__main__.py +0 -0
  123. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/announce.py +0 -0
  124. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/audio_source.py +0 -0
  125. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/backends.py +0 -0
  126. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/configcmd.py +0 -0
  127. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/corrections.py +0 -0
  128. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/history.py +0 -0
  129. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/models.py +0 -0
  130. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/servicecmd.py +0 -0
  131. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/cli/vocab.py +0 -0
  132. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/clipboard_read.py +0 -0
  133. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/commands.py +0 -0
  134. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/consent.py +0 -0
  135. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/context_terms.py +0 -0
  136. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/__init__.py +0 -0
  137. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/bias.py +0 -0
  138. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/__init__.py +0 -0
  139. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/base.py +0 -0
  140. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/catalog.py +0 -0
  141. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  142. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/fetch.py +0 -0
  143. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/lanes.py +0 -0
  144. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/sherpa.py +0 -0
  145. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/decoders/whispercpp.py +0 -0
  146. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/engine.py +0 -0
  147. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/core/pipeline.py +0 -0
  148. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  149. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/data/__init__.py +0 -0
  150. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/demo.py +0 -0
  151. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/display.py +0 -0
  152. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/feedback.py +0 -0
  153. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/focus.py +0 -0
  154. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/__init__.py +0 -0
  155. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/base.py +0 -0
  156. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/chain.py +0 -0
  157. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/evdev.py +0 -0
  158. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/pynput_backend.py +0 -0
  159. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/hotkeys/socket_backend.py +0 -0
  160. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/__init__.py +0 -0
  161. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/engine.py +0 -0
  162. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/pending.py +0 -0
  163. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/session.py +0 -0
  164. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ibus/textlimit.py +0 -0
  165. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/idle_audio.py +0 -0
  166. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/__init__.py +0 -0
  167. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/clean.py +0 -0
  168. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/metrics.py +0 -0
  169. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/quiet.py +0 -0
  170. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/review.py +0 -0
  171. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/teacher.py +0 -0
  172. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/learn/train.py +0 -0
  173. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/llm_api.py +0 -0
  174. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/mic_gain.py +0 -0
  175. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/models/__init__.py +0 -0
  176. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/models/silero_vad.onnx +0 -0
  177. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/numbers.py +0 -0
  178. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/pidlock.py +0 -0
  179. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/postcorrect.py +0 -0
  180. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/postprocess.py +0 -0
  181. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/power.py +0 -0
  182. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/presence.py +0 -0
  183. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/privacy.py +0 -0
  184. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/prompt.py +0 -0
  185. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/quiet_audio.py +0 -0
  186. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/recorder.py +0 -0
  187. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/report.py +0 -0
  188. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/server/__init__.py +0 -0
  189. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/server/__main__.py +0 -0
  190. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/server/app.py +0 -0
  191. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/server/config.py +0 -0
  192. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/server/runtime.py +0 -0
  193. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/service.py +0 -0
  194. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/__init__.py +0 -0
  195. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/extras.py +0 -0
  196. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/hotkey.py +0 -0
  197. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/speech.py +0 -0
  198. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/setup/ui.py +0 -0
  199. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/sounds/__init__.py +0 -0
  200. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/sounds/alert.wav +0 -0
  201. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/sounds/commit.wav +0 -0
  202. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/sounds/start.wav +0 -0
  203. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/sounds/stop.wav +0 -0
  204. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/spool.py +0 -0
  205. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/streaming.py +0 -0
  206. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/suggest.py +0 -0
  207. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tokens.py +0 -0
  208. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tray/__init__.py +0 -0
  209. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tray/_icons.py +0 -0
  210. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tray/_indicator.py +0 -0
  211. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tray/_pystray.py +0 -0
  212. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/__init__.py +0 -0
  213. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/base.py +0 -0
  214. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/edge_engine.py +0 -0
  215. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/espeak.py +0 -0
  216. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/piper_engine.py +0 -0
  217. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/player.py +0 -0
  218. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/tts/supertonic_engine.py +0 -0
  219. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/__init__.py +0 -0
  220. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/base.py +0 -0
  221. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/chain.py +0 -0
  222. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/clipboard.py +0 -0
  223. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/collecting.py +0 -0
  224. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/fcitx_bridge.py +0 -0
  225. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/ibus.py +0 -0
  226. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/manager.py +0 -0
  227. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/modifier_guard.py +0 -0
  228. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/paste.py +0 -0
  229. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/pynput_type.py +0 -0
  230. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/wtype.py +0 -0
  231. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/xdotool.py +0 -0
  232. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/typers/ydotool.py +0 -0
  233. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/ui.py +0 -0
  234. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/vad.py +0 -0
  235. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/watchdog.py +0 -0
  236. {python_voiceio-1.17.0 → python_voiceio-1.17.1}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.17.0
3
+ Version: 1.17.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.17.0"
7
+ version = "1.17.1"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.17.0
3
+ Version: 1.17.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -85,6 +85,94 @@ def test_check_permissions_flags_loose_and_clears_after_harden(tmp_path, monkeyp
85
85
  assert config.check_permissions() == []
86
86
 
87
87
 
88
+ @pytest.fixture
89
+ def umask_002():
90
+ """The user-private-group umask of Debian/Fedora desktops: plain mkdir
91
+ gives 0775, which is how the reported ~/.local/state/voiceio got there."""
92
+ import os
93
+ old = os.umask(0o002)
94
+ try:
95
+ yield
96
+ finally:
97
+ os.umask(old)
98
+
99
+
100
+ @posix_only
101
+ def test_private_dir_creates_0700_with_missing_parents(tmp_path, umask_002):
102
+ (tmp_path / "home").mkdir(mode=0o755)
103
+ target = tmp_path / "home" / ".local" / "state" / "voiceio"
104
+ assert config.private_dir(target) == target
105
+ assert _mode(target) == 0o700
106
+ assert _mode(target.parent) == 0o700 # XDG: created dirs are 0700
107
+ assert _mode(tmp_path / "home") == 0o755 # existing parents untouched
108
+
109
+
110
+ @posix_only
111
+ def test_private_dir_tightens_existing_lax_dir(tmp_path):
112
+ d = tmp_path / "voiceio"
113
+ d.mkdir()
114
+ d.chmod(0o775)
115
+ config.private_dir(d)
116
+ assert _mode(d) == 0o700
117
+
118
+
119
+ @posix_only
120
+ def test_crash_log_dir_is_private(tmp_path, monkeypatch, umask_002, capsys):
121
+ from voiceio import cli
122
+ log_dir = tmp_path / "state" / "voiceio"
123
+ monkeypatch.setattr(config, "LOG_DIR", log_dir)
124
+
125
+ def boom():
126
+ raise RuntimeError("crash before logging")
127
+
128
+ monkeypatch.setattr(cli, "main", boom)
129
+ with pytest.raises(SystemExit):
130
+ cli._entry_point()
131
+ assert (log_dir / "crash.log").exists()
132
+ assert _mode(log_dir) == 0o700
133
+ assert _mode(log_dir.parent) == 0o700
134
+
135
+
136
+ @posix_only
137
+ def test_error_notifier_state_dir_is_private(tmp_path, umask_002):
138
+ import logging
139
+ from voiceio.errorlog import RepeatedErrorNotifier
140
+ state = tmp_path / "state" / "voiceio" / "error-notify.json"
141
+ handler = RepeatedErrorNotifier(state_path=state, notify=lambda *a, **k: None)
142
+ handler.emit(logging.LogRecord("voiceio.x", logging.ERROR, __file__, 1, "boom", (), None))
143
+ assert state.exists()
144
+ assert _mode(state.parent) == 0o700
145
+
146
+
147
+ @posix_only
148
+ def test_secure_write_missing_parents_are_private(tmp_path, umask_002):
149
+ target = tmp_path / "config" / "voiceio" / "config.toml"
150
+ config.secure_write(target, "x = 1\n")
151
+ assert _mode(target.parent) == 0o700
152
+ assert _mode(target.parent.parent) == 0o700
153
+
154
+
155
+ @posix_only
156
+ def test_label_store_dirs_are_private(tmp_path, monkeypatch, umask_002):
157
+ from voiceio.learn import store
158
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
159
+ store.add_label("a.wav", [], source="test")
160
+ assert _mode(store.learn_dir()) == 0o700
161
+ assert _mode(config.data_dir()) == 0o700
162
+
163
+
164
+ @posix_only
165
+ def test_check_permissions_flags_lax_learn_dir(tmp_path, monkeypatch):
166
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
167
+ learn = config.data_dir() / "learn"
168
+ learn.mkdir(parents=True)
169
+ learn.chmod(0o775)
170
+ assert learn in {p for p, _, _ in config.check_permissions()}
171
+ config.harden_permissions()
172
+ assert _mode(learn) == 0o700
173
+ assert learn not in {p for p, _, _ in config.check_permissions()}
174
+
175
+
88
176
  # ── udev rule (replaces sudo chmod 0666 /dev/uinput) ─────────────────────
89
177
 
90
178
  def test_uinput_udev_rule_content():
@@ -280,3 +280,108 @@ def test_setup_offers_nightly_learning_once(monkeypatch):
280
280
  steps._offer_nightly_learning(state)
281
281
  steps._offer_nightly_learning(state) # already on: not asked again
282
282
  assert installed == [1] and len(asked) == 1
283
+
284
+
285
+ # ── root commands with no terminal (agent, launcher, GUI) ────────────
286
+
287
+ def _no_terminal(monkeypatch, *, sudo_cached=False, pkexec=True, display=True):
288
+ monkeypatch.setattr(plat, "_has_tty", lambda: False)
289
+ monkeypatch.setattr(plat, "_sudo_without_password", lambda: sudo_cached)
290
+ monkeypatch.setattr(plat.shutil, "which",
291
+ lambda name: "/usr/bin/pkexec" if pkexec and name == "pkexec" else None)
292
+ monkeypatch.delenv("DISPLAY", raising=False)
293
+ if display:
294
+ monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
295
+ else:
296
+ monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
297
+
298
+
299
+ def test_privileged_argv_unchanged_with_a_terminal(monkeypatch):
300
+ monkeypatch.setattr(plat, "_has_tty", lambda: True)
301
+ argv = ["sudo", "apt", "install", "ibus"]
302
+ assert plat.privileged_argv(argv) == argv
303
+
304
+
305
+ def test_privileged_argv_leaves_unprivileged_commands_alone(monkeypatch):
306
+ _no_terminal(monkeypatch, pkexec=False)
307
+ assert plat.privileged_argv(["brew", "install", "x"]) == ["brew", "install", "x"]
308
+
309
+
310
+ def test_privileged_argv_uses_pkexec_without_a_terminal(monkeypatch):
311
+ _no_terminal(monkeypatch)
312
+ assert plat.privileged_argv(["sudo", "apt", "install", "ibus", "gcc"]) == [
313
+ "pkexec", "apt", "install", "-y", "ibus", "gcc"]
314
+ assert plat.privileged_argv(["sudo", "pacman", "-S", "ibus"]) == [
315
+ "pkexec", "pacman", "-S", "--noconfirm", "ibus"]
316
+
317
+
318
+ def test_privileged_argv_runs_shell_scripts_with_inner_sudo_under_pkexec(monkeypatch):
319
+ _no_terminal(monkeypatch)
320
+ script = ["sh", "-c", "printf x | sudo tee /etc/udev/rules.d/r > /dev/null"]
321
+ assert plat.privileged_argv(script) == ["pkexec", *script]
322
+
323
+
324
+ def test_privileged_argv_prefers_cached_sudo(monkeypatch):
325
+ _no_terminal(monkeypatch, sudo_cached=True)
326
+ assert plat.privileged_argv(["sudo", "apt", "install", "x"]) == [
327
+ "sudo", "-n", "apt", "install", "-y", "x"]
328
+
329
+
330
+ @pytest.mark.parametrize("pkexec,display", [(False, True), (True, False)])
331
+ def test_privileged_argv_gives_up_without_pkexec_or_desktop(monkeypatch, pkexec, display):
332
+ _no_terminal(monkeypatch, pkexec=pkexec, display=display)
333
+ assert plat.privileged_argv(["sudo", "apt", "install", "x"]) is None
334
+
335
+
336
+ def test_noninteractive_install_deps_without_terminal_prints_command(cfg_paths, monkeypatch, capsys):
337
+ _patch_noninteractive(monkeypatch)
338
+ monkeypatch.setattr("voiceio.platform.check_system_deps", lambda: ["portaudio"])
339
+ monkeypatch.setattr(plat, "system_deps_install_cmd", lambda keys: "sudo apt install libportaudio2")
340
+ monkeypatch.setattr(plat, "privileged_argv", lambda argv: None)
341
+ ran = []
342
+ monkeypatch.setattr(noninteractive.subprocess, "run", lambda *a, **k: ran.append(a))
343
+ code = noninteractive.run_setup_noninteractive({}, install_deps=True)
344
+ assert code == 4
345
+ assert ran == []
346
+ assert "sudo apt install libportaudio2" in capsys.readouterr().out
347
+
348
+
349
+ def test_noninteractive_install_deps_runs_pkexec_argv(cfg_paths, monkeypatch):
350
+ _patch_noninteractive(monkeypatch)
351
+ state = {"missing": ["portaudio"]}
352
+ monkeypatch.setattr("voiceio.platform.check_system_deps", lambda: state["missing"])
353
+ monkeypatch.setattr(plat, "system_deps_install_cmd", lambda keys: "sudo apt install libportaudio2")
354
+ _no_terminal(monkeypatch)
355
+ ran = []
356
+
357
+ class _Done:
358
+ returncode = 0
359
+
360
+ def fake_run(argv, **kw):
361
+ ran.append(argv)
362
+ state["missing"] = []
363
+ return _Done()
364
+
365
+ monkeypatch.setattr(noninteractive.subprocess, "run", fake_run)
366
+ assert noninteractive.run_setup_noninteractive({}, install_deps=True) == 0
367
+ assert ran == [["pkexec", "apt", "install", "-y", "libportaudio2"]]
368
+
369
+
370
+ def test_doctor_privileged_fix_asks_first_and_never_runs_when_declined(monkeypatch):
371
+ from voiceio.cli import doctor
372
+ ran = []
373
+ monkeypatch.setattr("subprocess.run", lambda *a, **k: ran.append(a))
374
+ monkeypatch.setattr(doctor, "_confirm_privileged", lambda d, c: False)
375
+ assert doctor._run_privileged("Fix x", ["sudo", "true"], 5) is None
376
+ assert ran == []
377
+
378
+
379
+ def test_doctor_privileged_fix_without_terminal_prints_command(monkeypatch, capsys):
380
+ from voiceio.cli import doctor
381
+ ran = []
382
+ monkeypatch.setattr("subprocess.run", lambda *a, **k: ran.append(a))
383
+ monkeypatch.setattr(doctor, "_confirm_privileged", lambda d, c: True)
384
+ monkeypatch.setattr(plat, "privileged_argv", lambda argv: None)
385
+ assert doctor._run_privileged("Fix x", ["sudo", "udevadm", "trigger"], 5) is False
386
+ assert ran == []
387
+ assert "sudo udevadm trigger" in capsys.readouterr().out
@@ -499,3 +499,54 @@ def test_chain_passes_language_to_supertonic():
499
499
  from voiceio.tts import chain
500
500
  engine = chain._create("supertonic", TTSConfig(engine="supertonic", language="de"))
501
501
  assert engine.name == "supertonic" and engine._language == "de"
502
+
503
+
504
+ # ── no engine installed: the default config stays quiet ─────────────
505
+
506
+ def _no_engines(cfg):
507
+ names = ["piper", "supertonic", "edge-tts", "espeak"] if cfg.engine == "auto" else [cfg.engine]
508
+ return [(n, ProbeResult(ok=False, reason=f"{n} not installed")) for n in names]
509
+
510
+
511
+ def test_doctor_tts_auto_without_engine_is_quiet_off():
512
+ from voiceio import health
513
+ with patch("voiceio.tts.probe_all", side_effect=_no_engines):
514
+ status = health._tts_status(TTSConfig())
515
+ assert status.startswith("off")
516
+ assert "enabled but" not in status
517
+ assert "⚠" not in health._feature_line("Text-to-speech", status)
518
+
519
+
520
+ def test_doctor_tts_named_engine_missing_still_warns():
521
+ from voiceio import health
522
+ with patch("voiceio.tts.probe_all", side_effect=_no_engines):
523
+ status = health._tts_status(TTSConfig(engine="piper"))
524
+ assert status.startswith("enabled but piper unavailable")
525
+
526
+
527
+ def test_doctor_tts_on_when_engine_present():
528
+ from voiceio import health
529
+ with patch("voiceio.tts.probe_all",
530
+ return_value=[("piper", ProbeResult(ok=False, reason="x")),
531
+ ("espeak", ProbeResult(ok=True))]):
532
+ assert health._tts_status(TTSConfig()) == "on (espeak)"
533
+
534
+
535
+ def test_doctor_tts_disabled_is_off():
536
+ from voiceio import health
537
+ assert health._tts_status(TTSConfig(enabled=False)) == "off"
538
+
539
+
540
+ def test_auto_select_without_engine_logs_no_warning(caplog):
541
+ import logging
542
+ from voiceio.tts.chain import select, _create
543
+
544
+ def none_ok(name, cfg):
545
+ engine = _create(name, cfg)
546
+ engine.probe = lambda: ProbeResult(ok=False, reason="not installed")
547
+ return engine
548
+
549
+ with patch("voiceio.tts.chain._create", side_effect=none_ok), \
550
+ caplog.at_level(logging.DEBUG, logger="voiceio.tts.chain"):
551
+ assert select(TTSConfig()) is None
552
+ assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
@@ -0,0 +1 @@
1
+ __version__ = "1.17.1"
@@ -202,8 +202,11 @@ class VoiceIO:
202
202
  # Create a second hotkey backend for TTS
203
203
  self._tts_hotkey = hotkey_chain.select(platform, cfg.hotkey.backend)
204
204
  log.info("TTS: engine=%s, hotkey=%s", self._tts_engine.name, cfg.tts.hotkey)
205
+ elif cfg.tts.engine == "auto":
206
+ # The default config: read aloud once an engine is installed.
207
+ log.info("TTS: no engine installed, read-aloud hotkey off")
205
208
  else:
206
- log.warning("TTS enabled but no engine available")
209
+ log.warning("TTS: engine '%s' is configured but unavailable", cfg.tts.engine)
207
210
 
208
211
  # Optional key that cancels a dictation (read only, never grabbed: the
209
212
  # key also reaches the focused app). Needs a backend that sees keys.
@@ -1007,11 +1010,11 @@ class VoiceIO:
1007
1010
  self._cancel_hotkey.start(self.cfg.hotkey.cancel_key, self.cancel)
1008
1011
 
1009
1012
  def run(self) -> None:
1010
- from voiceio.config import PID_PATH, LOG_DIR
1013
+ from voiceio.config import PID_PATH, LOG_DIR, private_dir
1011
1014
 
1012
1015
  self._typers.redetect_at_start()
1013
1016
 
1014
- LOG_DIR.mkdir(parents=True, exist_ok=True)
1017
+ private_dir(LOG_DIR)
1015
1018
  self._pid_fd = open(PID_PATH, "a") # not "w": the running daemon's PID stays
1016
1019
  try:
1017
1020
  from voiceio.pidlock import lock_pid_file
@@ -112,9 +112,9 @@ def _cmd_run(args: argparse.Namespace) -> None:
112
112
  console.setLevel(getattr(logging, cfg.daemon.log_level))
113
113
 
114
114
  # File: always log DEBUG to rotating file
115
- from voiceio.config import LOG_DIR, LOG_PATH
115
+ from voiceio.config import LOG_DIR, LOG_PATH, private_dir
116
116
  from logging.handlers import RotatingFileHandler
117
- LOG_DIR.mkdir(parents=True, exist_ok=True)
117
+ private_dir(LOG_DIR)
118
118
  file_handler = RotatingFileHandler(
119
119
  str(LOG_PATH), maxBytes=2_000_000, backupCount=2,
120
120
  )
@@ -212,8 +212,8 @@ def _entry_point() -> None:
212
212
  import traceback
213
213
  tb = traceback.format_exc()
214
214
  try:
215
- from voiceio.config import LOG_DIR
216
- LOG_DIR.mkdir(parents=True, exist_ok=True)
215
+ from voiceio.config import LOG_DIR, private_dir
216
+ private_dir(LOG_DIR)
217
217
  with open(LOG_DIR / "crash.log", "a", encoding="utf-8") as f:
218
218
  f.write(f"\n===== {datetime.datetime.now().isoformat()} =====\n"
219
219
  f"argv: {sys.argv}\n{tb}")
@@ -42,6 +42,35 @@ def _confirm_privileged(description: str, command_str: str) -> bool:
42
42
  return answer in ("y", "yes")
43
43
 
44
44
 
45
+ def _run_privileged(description: str, argv: list[str], timeout: float,
46
+ cmd_str: str | None = None) -> bool | None:
47
+ """Ask first, then run a root command: sudo with a terminal, else pkexec
48
+ (see platform.privileged_argv), else print it for the user to run.
49
+
50
+ Returns True when it ran, False when it failed or could not run, None
51
+ when the user declined. Raises nothing.
52
+ """
53
+ import shlex
54
+ import subprocess
55
+
56
+ from voiceio import platform as plat
57
+ cmd_str = cmd_str or shlex.join(argv)
58
+ if not _confirm_privileged(description, cmd_str):
59
+ return None
60
+ run_argv = plat.privileged_argv(argv)
61
+ if run_argv is None:
62
+ print(" No terminal for sudo to ask your password, and no pkexec. Run it yourself:")
63
+ print(f" {cmd_str}")
64
+ return False
65
+ try:
66
+ subprocess.run(run_argv, check=True, timeout=timeout)
67
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
68
+ print(f" Failed: {e} — run the command above manually.")
69
+ return False
70
+ print(" Done.")
71
+ return True
72
+
73
+
45
74
  def _cmd_doctor(args: argparse.Namespace) -> None:
46
75
  """Run diagnostic health check, offer to fix issues."""
47
76
  if not sys.platform.startswith("linux"):
@@ -96,12 +125,17 @@ def _cmd_doctor(args: argparse.Namespace) -> None:
96
125
  import subprocess
97
126
 
98
127
  if missing_deps and dep_cmd:
99
- print(f" Fixing system packages: {dep_cmd}")
100
- try:
101
- subprocess.run(dep_cmd.split(), check=True, timeout=600)
102
- print(" Done.")
103
- except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
104
- print(f" Failed: {e} — run the command above manually.")
128
+ if dep_cmd.startswith("sudo "):
129
+ if _run_privileged("Install system packages", dep_cmd.split(), 600,
130
+ cmd_str=dep_cmd) is None:
131
+ print(" Skipped system packages (declined).")
132
+ else:
133
+ print(f" Fixing system packages: {dep_cmd}")
134
+ try:
135
+ subprocess.run(dep_cmd.split(), check=True, timeout=600)
136
+ print(" Done.")
137
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
138
+ print(f" Failed: {e} — run the command above manually.")
105
139
 
106
140
  fixed_any = False
107
141
 
@@ -114,11 +148,12 @@ def _cmd_doctor(args: argparse.Namespace) -> None:
114
148
  if not b.ok and b.fix_cmd:
115
149
  cmd_str = " ".join(b.fix_cmd)
116
150
  if _is_privileged_cmd(b.fix_cmd):
117
- if not _confirm_privileged(f"Fix {b.name}", cmd_str):
151
+ ran = _run_privileged(f"Fix {b.name}", b.fix_cmd, 120, cmd_str=cmd_str)
152
+ if ran is None:
118
153
  print(f" Skipped {b.name} (declined).")
119
- continue
120
- else:
121
- print(f" Fixing {b.name}: {cmd_str}")
154
+ fixed_any = fixed_any or bool(ran)
155
+ continue
156
+ print(f" Fixing {b.name}: {cmd_str}")
122
157
  try:
123
158
  subprocess.run(b.fix_cmd, check=True, timeout=30)
124
159
  print(" Done.")
@@ -536,7 +536,8 @@ def cmd_maintain(args: argparse.Namespace) -> None:
536
536
  from voiceio.learn import quiet
537
537
  learn_cfg = _maintain_settings(args)
538
538
  if getattr(args, "when_idle", False) and os.environ.get(quiet.WORKER_ENV) != "1":
539
- store.learn_dir().mkdir(parents=True, exist_ok=True)
539
+ from voiceio.config import private_dir
540
+ private_dir(store.learn_dir())
540
541
  code = quiet.run_while_away(
541
542
  quiet.worker_argv(), max_load=learn_cfg.max_load,
542
543
  away_minutes=learn_cfg.away_minutes, wait_hours=learn_cfg.wait_hours,
@@ -165,8 +165,13 @@ def _cmd_uninstall() -> None:
165
165
  continue
166
166
  cmd = f"sudo rm {rule_path}"
167
167
  if _confirm_privileged(f"Remove the voiceio {what} udev rule", cmd):
168
+ from voiceio.platform import privileged_argv
169
+ argv = privileged_argv(["sudo", "rm", rule_path])
170
+ if argv is None:
171
+ print(f" No terminal for sudo to ask your password. Run manually: {cmd}")
172
+ continue
168
173
  try:
169
- subprocess.run(["sudo", "rm", rule_path], timeout=30)
174
+ subprocess.run(argv, timeout=120)
170
175
  removed.append(rule_path)
171
176
  except (FileNotFoundError, subprocess.TimeoutExpired):
172
177
  print(f" Failed. Run manually: {cmd}")
@@ -247,6 +247,9 @@ class PostCorrectConfig:
247
247
 
248
248
  @dataclass
249
249
  class TTSConfig:
250
+ # With engine = "auto" this means "read aloud once an engine is
251
+ # installed": no engine is a quiet no-op (doctor says "off"), never a
252
+ # warning. Naming an engine that is missing does warn.
250
253
  enabled: bool = True
251
254
  engine: str = "auto" # "auto" | "piper" | "supertonic" | "espeak" | "edge-tts"
252
255
  hotkey: str = "ctrl+alt+s" # "s" for speak
@@ -452,9 +455,17 @@ def _content_files() -> list[Path]:
452
455
  ]
453
456
 
454
457
 
458
+ def data_dir() -> Path:
459
+ """$XDG_DATA_HOME/voiceio: the learning corpus and the IBus launcher.
460
+ Read at call time, like the learn paths built on it."""
461
+ base = os.environ.get("XDG_DATA_HOME", "")
462
+ root = Path(base) if os.path.isabs(base) else Path.home() / ".local" / "share"
463
+ return root / "voiceio"
464
+
465
+
455
466
  def _content_dirs() -> list[Path]:
456
467
  """Directories that hold user content (0700)."""
457
- return [CONFIG_DIR, LOG_DIR, RECORDINGS_DIR]
468
+ return [CONFIG_DIR, LOG_DIR, RECORDINGS_DIR, data_dir(), data_dir() / "learn"]
458
469
 
459
470
 
460
471
  def _chmod(path: Path, mode: int) -> None:
@@ -467,6 +478,33 @@ def _chmod(path: Path, mode: int) -> None:
467
478
  log.debug("Could not chmod %s", path, exc_info=True)
468
479
 
469
480
 
481
+ def private_dir(path: Path) -> Path:
482
+ """Create `path` (and any missing parents) as 0700 and return it.
483
+
484
+ `mkdir(parents=True)` gives every new directory the umask's mode, which
485
+ is 0775 under the 002 umask of user-private-group distros (Debian,
486
+ Fedora): the state dir was group-readable until the daemon's startup
487
+ hardening caught up, and never fixed when only setup, doctor or the CLI
488
+ ran. Missing parents (e.g. ~/.local/state) get 0700 too, as the XDG
489
+ base directory spec asks. Existing parents are left alone.
490
+ """
491
+ missing: list[Path] = []
492
+ p = path
493
+ while not p.exists() and p.parent != p:
494
+ missing.append(p)
495
+ p = p.parent
496
+ for d in reversed(missing):
497
+ try:
498
+ d.mkdir(mode=_SECURE_DIR)
499
+ except FileExistsError:
500
+ pass
501
+ _chmod(d, _SECURE_DIR) # mkdir's mode is masked by the umask
502
+ if (not missing and sys.platform != "win32" and path.is_dir()
503
+ and stat.S_IMODE(path.stat().st_mode) != _SECURE_DIR):
504
+ _chmod(path, _SECURE_DIR)
505
+ return path
506
+
507
+
470
508
  def secure_write(path: Path, text: str, *, encoding: str = "utf-8") -> None:
471
509
  """Write text to a file guaranteed to be 0600 with a 0700 parent dir.
472
510
 
@@ -474,8 +512,7 @@ def secure_write(path: Path, text: str, *, encoding: str = "utf-8") -> None:
474
512
  over the target, so there is no world-readable window and a crash never
475
513
  leaves a half-written file.
476
514
  """
477
- path.parent.mkdir(parents=True, exist_ok=True)
478
- _chmod(path.parent, _SECURE_DIR)
515
+ private_dir(path.parent)
479
516
  tmp = path.with_name(f".{path.name}.tmp")
480
517
  fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _SECURE_FILE)
481
518
  try:
@@ -117,8 +117,8 @@ class Transcriber:
117
117
  "cpu_threads": self._cfg.cpu_threads,
118
118
  "language": language,
119
119
  })
120
- from voiceio.config import LOG_DIR
121
- LOG_DIR.mkdir(parents=True, exist_ok=True)
120
+ from voiceio.config import LOG_DIR, private_dir
121
+ private_dir(LOG_DIR)
122
122
  self._stderr_path = LOG_DIR / "worker.log"
123
123
  # Append (rotating at 1 MB) so a crash's stack dump survives the restart.
124
124
  if self._stderr_path.exists() and self._stderr_path.stat().st_size > 1_000_000:
@@ -7,6 +7,7 @@ import re
7
7
  import threading
8
8
  from pathlib import Path
9
9
 
10
+ from voiceio import config
10
11
  from voiceio.config import CORRECTIONS_PATH, FLAGGED_PATH
11
12
 
12
13
  log = logging.getLogger(__name__)
@@ -59,7 +60,7 @@ class CorrectionDict:
59
60
  """Write corrections.json atomically."""
60
61
  with self._lock:
61
62
  data = {orig: right for orig, right in self._corrections.values()}
62
- self._path.parent.mkdir(parents=True, exist_ok=True)
63
+ config.private_dir(self._path.parent)
63
64
  tmp = self._path.with_suffix(".tmp")
64
65
  tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n",
65
66
  encoding="utf-8")
@@ -110,7 +111,7 @@ class CorrectionDict:
110
111
  return
111
112
  word = word.strip()
112
113
  try:
113
- self._flagged_path.parent.mkdir(parents=True, exist_ok=True)
114
+ config.private_dir(self._flagged_path.parent)
114
115
  with open(self._flagged_path, "a", encoding="utf-8") as f:
115
116
  f.write(word + "\n")
116
117
  log.info("Flagged word: '%s'", word)
@@ -156,7 +156,8 @@ class RepeatedErrorNotifier(logging.Handler):
156
156
  fire = len(seen[key]) >= REPEATS and state.get("notified") != today
157
157
  if fire:
158
158
  state["notified"] = today
159
- self._path.parent.mkdir(parents=True, exist_ok=True)
159
+ from voiceio.config import private_dir
160
+ private_dir(self._path.parent)
160
161
  self._path.write_text(json.dumps(state))
161
162
  if fire:
162
163
  notify = self._notify
@@ -222,17 +222,7 @@ def _check_features() -> FeatureGroups:
222
222
  else:
223
223
  groups.processing["LLM cleanup"] = "off (enable in config or re-run 'voiceio setup')"
224
224
 
225
- # TTS
226
- if cfg.tts.enabled:
227
- from voiceio.tts import probe_all as tts_probe_all
228
- results = tts_probe_all(cfg.tts)
229
- active = next((name for name, probe in results if probe.ok), None)
230
- if active:
231
- groups.processing["Text-to-speech"] = f"on ({active})"
232
- else:
233
- groups.processing["Text-to-speech"] = "enabled but no engine available"
234
- else:
235
- groups.processing["Text-to-speech"] = "off"
225
+ groups.processing["Text-to-speech"] = _tts_status(cfg.tts)
236
226
 
237
227
  # ── Accuracy (learning stack) ──
238
228
 
@@ -301,6 +291,30 @@ def _check_features() -> FeatureGroups:
301
291
  return groups
302
292
 
303
293
 
294
+ TTS_INSTALL_HINT = "pip install 'python-voiceio[tts]' or install espeak-ng"
295
+
296
+
297
+ def _tts_status(tts_cfg) -> str:
298
+ """Doctor's Text-to-speech line.
299
+
300
+ `enabled = true` with `engine = "auto"` (the default) means "read aloud
301
+ when an engine is installed", so no engine there is a quiet "off", not a
302
+ warning: the user never asked for a particular engine. Naming an engine
303
+ that is missing is a real misconfiguration and still warns.
304
+ """
305
+ if not tts_cfg.enabled:
306
+ return "off"
307
+ from voiceio.tts import probe_all as tts_probe_all
308
+ results = tts_probe_all(tts_cfg)
309
+ active = next((name for name, probe in results if probe.ok), None)
310
+ if active:
311
+ return f"on ({active})"
312
+ if tts_cfg.engine == "auto":
313
+ return f"off (no engine installed: {TTS_INSTALL_HINT})"
314
+ reason = next((probe.reason for _, probe in results if probe.reason), "")
315
+ return f"enabled but {tts_cfg.engine} unavailable" + (f" ({reason})" if reason else "")
316
+
317
+
304
318
  def _decoder_status(cfg) -> dict[str, str]:
305
319
  """For a sherpa-onnx model: whether it can be biased, and why not. For
306
320
  Whisper: whether Parakeet would do better here."""
@@ -52,7 +52,8 @@ def _load_state() -> dict:
52
52
 
53
53
  def _save_state(state: dict) -> None:
54
54
  try:
55
- _HINTS_PATH.parent.mkdir(parents=True, exist_ok=True)
55
+ from voiceio.config import private_dir
56
+ private_dir(_HINTS_PATH.parent)
56
57
  _HINTS_PATH.write_text(json.dumps(state))
57
58
  except Exception:
58
59
  log.debug("Failed to save hints state", exc_info=True)
@@ -51,8 +51,7 @@ def append(
51
51
  return
52
52
  p = path or HISTORY_PATH
53
53
  try:
54
- p.parent.mkdir(parents=True, exist_ok=True)
55
- config._chmod(p.parent, config._SECURE_DIR)
54
+ config.private_dir(p.parent)
56
55
  from voiceio import __version__
57
56
  # The version lets `doctor` judge the current release on its own
58
57
  # numbers instead of months of older ones.
@@ -95,7 +95,8 @@ def refresh_stale_launcher() -> bool:
95
95
 
96
96
 
97
97
  def _write_launcher() -> None:
98
- LAUNCHER_DIR.mkdir(parents=True, exist_ok=True)
98
+ from voiceio.config import private_dir
99
+ private_dir(LAUNCHER_DIR)
99
100
  LAUNCHER_PATH.write_text(_launcher_script())
100
101
  LAUNCHER_PATH.chmod(LAUNCHER_PATH.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
101
102