python-voiceio 1.8.3__tar.gz → 1.9.0__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 (216) hide show
  1. {python_voiceio-1.8.3/python_voiceio.egg-info → python_voiceio-1.9.0}/PKG-INFO +1 -1
  2. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/pyproject.toml +1 -1
  3. {python_voiceio-1.8.3 → python_voiceio-1.9.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/SOURCES.txt +2 -0
  5. python_voiceio-1.9.0/tests/test_context_terms.py +253 -0
  6. python_voiceio-1.9.0/voiceio/__init__.py +1 -0
  7. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/app.py +31 -5
  8. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/config.py +4 -0
  9. python_voiceio-1.9.0/voiceio/context_terms.py +253 -0
  10. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/bias.py +52 -10
  11. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/engine.py +10 -4
  12. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/health.py +6 -0
  13. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/streaming.py +8 -0
  14. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tokens.py +17 -0
  15. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vocabulary.py +6 -15
  16. python_voiceio-1.8.3/voiceio/__init__.py +0 -1
  17. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/LICENSE +0 -0
  18. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/README.md +0 -0
  19. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  20. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  21. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/requires.txt +0 -0
  22. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/top_level.txt +0 -0
  23. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/setup.cfg +0 -0
  24. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_app_wiring.py +0 -0
  25. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_audio_quality.py +0 -0
  26. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_audio_source.py +0 -0
  27. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_cancel.py +0 -0
  29. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_cli.py +0 -0
  30. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_clipboard_read.py +0 -0
  31. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_clipboard_typer.py +0 -0
  32. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_commands.py +0 -0
  33. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_concurrency_lockdown.py +0 -0
  34. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_config.py +0 -0
  35. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_corrections.py +0 -0
  36. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_decoders.py +0 -0
  37. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_engine.py +0 -0
  38. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_evdev_uaccess.py +0 -0
  39. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_fallback.py +0 -0
  40. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_fcitx_bridge.py +0 -0
  41. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_feedback_stream.py +0 -0
  42. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_focus.py +0 -0
  43. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_health.py +0 -0
  44. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_hints.py +0 -0
  45. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_history.py +0 -0
  46. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_hotkey_modes.py +0 -0
  47. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_engine_limits.py +0 -0
  48. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_engine_listener.py +0 -0
  49. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_pending.py +0 -0
  50. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_pids.py +0 -0
  51. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_ping.py +0 -0
  52. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_session.py +0 -0
  53. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_textlimit.py +0 -0
  54. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_typer.py +0 -0
  55. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_idle_audio.py +0 -0
  56. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_clean.py +0 -0
  57. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_import.py +0 -0
  58. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_language.py +0 -0
  59. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_metrics.py +0 -0
  60. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_pipeline.py +0 -0
  61. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_quiet.py +0 -0
  62. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_review.py +0 -0
  63. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_targets.py +0 -0
  64. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_llm.py +0 -0
  65. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_llm_api.py +0 -0
  66. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_modifier_guard.py +0 -0
  67. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_numbers.py +0 -0
  68. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_paste_last.py +0 -0
  69. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_pipeline.py +0 -0
  70. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_platform.py +0 -0
  71. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_postcorrect.py +0 -0
  72. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_postprocess.py +0 -0
  73. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_prebuffer.py +0 -0
  74. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_prompt.py +0 -0
  75. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_quiet_audio.py +0 -0
  76. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_recorder_integration.py +0 -0
  77. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_retention.py +0 -0
  78. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_robustness.py +0 -0
  79. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_security_hardening.py +0 -0
  80. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_server.py +0 -0
  81. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_service.py +0 -0
  82. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_setup.py +0 -0
  83. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_streaming.py +0 -0
  84. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_suggest.py +0 -0
  85. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_tokens.py +0 -0
  86. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_transcriber.py +0 -0
  87. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_tts.py +0 -0
  88. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_typer_manager.py +0 -0
  89. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_vad.py +0 -0
  90. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_vocabulary.py +0 -0
  91. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_wordfreq.py +0 -0
  92. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_worker_decode.py +0 -0
  93. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/__main__.py +0 -0
  94. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/audio_source.py +0 -0
  95. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/backends.py +0 -0
  96. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/__init__.py +0 -0
  97. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/configcmd.py +0 -0
  98. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/corrections.py +0 -0
  99. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/doctor.py +0 -0
  100. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/history.py +0 -0
  101. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/learn.py +0 -0
  102. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/models.py +0 -0
  103. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/servicecmd.py +0 -0
  104. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/uninstall.py +0 -0
  105. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/vocab.py +0 -0
  106. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/clipboard_read.py +0 -0
  107. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/commands.py +0 -0
  108. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/consent.py +0 -0
  109. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/__init__.py +0 -0
  110. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/__init__.py +0 -0
  111. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/base.py +0 -0
  112. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/catalog.py +0 -0
  113. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  114. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  115. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/lanes.py +0 -0
  116. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/sherpa.py +0 -0
  117. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/whispercpp.py +0 -0
  118. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/pipeline.py +0 -0
  119. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/corrections.py +0 -0
  120. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  121. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/data/__init__.py +0 -0
  122. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/demo.py +0 -0
  123. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/display.py +0 -0
  124. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/feedback.py +0 -0
  125. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/focus.py +0 -0
  126. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hints.py +0 -0
  127. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/history.py +0 -0
  128. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/__init__.py +0 -0
  129. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/base.py +0 -0
  130. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/chain.py +0 -0
  131. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/evdev.py +0 -0
  132. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  133. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/socket_backend.py +0 -0
  134. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/__init__.py +0 -0
  135. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/engine.py +0 -0
  136. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/install.py +0 -0
  137. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/pending.py +0 -0
  138. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/session.py +0 -0
  139. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/textlimit.py +0 -0
  140. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/idle_audio.py +0 -0
  141. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/__init__.py +0 -0
  142. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/augment.py +0 -0
  143. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/clean.py +0 -0
  144. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/evaluate.py +0 -0
  145. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/importer.py +0 -0
  146. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/metrics.py +0 -0
  147. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/quiet.py +0 -0
  148. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/review.py +0 -0
  149. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/store.py +0 -0
  150. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/teacher.py +0 -0
  151. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/train.py +0 -0
  152. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/llm.py +0 -0
  153. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/llm_api.py +0 -0
  154. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/models/__init__.py +0 -0
  155. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/models/silero_vad.onnx +0 -0
  156. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/numbers.py +0 -0
  157. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/pidlock.py +0 -0
  158. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/platform.py +0 -0
  159. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/postcorrect.py +0 -0
  160. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/postprocess.py +0 -0
  161. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/power.py +0 -0
  162. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/privacy.py +0 -0
  163. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/prompt.py +0 -0
  164. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/quiet_audio.py +0 -0
  165. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/recorder.py +0 -0
  166. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/retention.py +0 -0
  167. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/__init__.py +0 -0
  168. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/__main__.py +0 -0
  169. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/app.py +0 -0
  170. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/config.py +0 -0
  171. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/runtime.py +0 -0
  172. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/service.py +0 -0
  173. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/__init__.py +0 -0
  174. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/configfile.py +0 -0
  175. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/extras.py +0 -0
  176. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/hotkey.py +0 -0
  177. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/noninteractive.py +0 -0
  178. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/speech.py +0 -0
  179. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/steps.py +0 -0
  180. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/system.py +0 -0
  181. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/ui.py +0 -0
  182. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/__init__.py +0 -0
  183. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/commit.wav +0 -0
  184. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/start.wav +0 -0
  185. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/stop.wav +0 -0
  186. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/suggest.py +0 -0
  187. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/__init__.py +0 -0
  188. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_icons.py +0 -0
  189. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_indicator.py +0 -0
  190. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_pystray.py +0 -0
  191. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/__init__.py +0 -0
  192. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/base.py +0 -0
  193. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/chain.py +0 -0
  194. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/edge_engine.py +0 -0
  195. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/espeak.py +0 -0
  196. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/piper_engine.py +0 -0
  197. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/player.py +0 -0
  198. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/__init__.py +0 -0
  199. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/base.py +0 -0
  200. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/chain.py +0 -0
  201. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/clipboard.py +0 -0
  202. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/collecting.py +0 -0
  203. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/fcitx_bridge.py +0 -0
  204. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/ibus.py +0 -0
  205. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/manager.py +0 -0
  206. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/modifier_guard.py +0 -0
  207. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/paste.py +0 -0
  208. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/pynput_type.py +0 -0
  209. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/wtype.py +0 -0
  210. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/xdotool.py +0 -0
  211. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/ydotool.py +0 -0
  212. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ui.py +0 -0
  213. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vad.py +0 -0
  214. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vocab_stats.py +0 -0
  215. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/watchdog.py +0 -0
  216. {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.8.3
3
+ Version: 1.9.0
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.8.3"
7
+ version = "1.9.0"
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.8.3
3
+ Version: 1.9.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -18,6 +18,7 @@ tests/test_clipboard_typer.py
18
18
  tests/test_commands.py
19
19
  tests/test_concurrency_lockdown.py
20
20
  tests/test_config.py
21
+ tests/test_context_terms.py
21
22
  tests/test_corrections.py
22
23
  tests/test_decoders.py
23
24
  tests/test_engine.py
@@ -85,6 +86,7 @@ voiceio/clipboard_read.py
85
86
  voiceio/commands.py
86
87
  voiceio/config.py
87
88
  voiceio/consent.py
89
+ voiceio/context_terms.py
88
90
  voiceio/corrections.py
89
91
  voiceio/demo.py
90
92
  voiceio/display.py
@@ -0,0 +1,253 @@
1
+ """On-screen vocabulary: term extraction, budget share, capture deadline, and
2
+ that capture never sits on the hotkey path."""
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from voiceio import context_terms
12
+ from voiceio.context_terms import ScreenContext, capture, extract_terms
13
+ from voiceio.core.bias import SCREEN_TOKEN_SHARE, BiasProvider
14
+ from voiceio.core.decoders.base import BiasMode, Capabilities
15
+ from voiceio.tokens import count_tokens
16
+
17
+ HOTWORDS = Capabilities(bias=BiasMode.HOTWORDS | BiasMode.PROMPT, max_bias_tokens=120)
18
+
19
+
20
+ # ── extraction ────────────────────────────────────────────────────────────
21
+
22
+
23
+ class TestExtract:
24
+ def test_rare_names_camelcase_acronyms(self):
25
+ terms = extract_terms(["Kalshi pricing - Discord",
26
+ "The HyperNEAT paper uses OpenRouter and the OKR doc."])
27
+ assert {"Kalshi", "HyperNEAT", "OpenRouter", "OKR"} <= set(terms)
28
+
29
+ def test_common_words_and_app_names_are_not_terms(self):
30
+ terms = extract_terms(["Inbox - Google Chrome",
31
+ "Please check the API before the meeting with Hugo."])
32
+ assert terms == []
33
+
34
+ def test_multiword_name_but_not_a_greeting(self):
35
+ terms = extract_terms(["", "Hey Kosh, ask Tarrant Fnordwick about it."])
36
+ assert "Tarrant Fnordwick" in terms
37
+ assert "Kosh" in terms
38
+ assert not any(t.startswith("Hey") for t in terms)
39
+ # A name and its own parts would spend tokens twice on one word.
40
+ assert "Tarrant" not in terms and "Fnordwick" not in terms
41
+
42
+ def test_identifiers_split_numbers_dropped(self):
43
+ terms = extract_terms(["", "see github.com/Hugo0/voiceio and v2 of x86_64 build 2026"])
44
+ assert "voiceio" in terms
45
+ assert not any(any(c.isdigit() for c in t) for t in terms)
46
+
47
+ def test_exclude_is_case_insensitive_and_dedupes_spellings(self):
48
+ terms = extract_terms(["", "Kalshi kalshi KALSHI Zorblax"], exclude=["zorblax"])
49
+ assert [t.lower() for t in terms] == ["kalshi"]
50
+
51
+ def test_vocabulary_long_tail_ranks_first(self):
52
+ terms = extract_terms(["", "Zorblax met Quuxley at Wibblecorp."],
53
+ prefer=["Wibblecorp"])
54
+ assert terms[0] == "Wibblecorp"
55
+
56
+ def test_title_beats_selection_on_ties(self):
57
+ terms = extract_terms(["Quuxley", "Zorblax"])
58
+ assert terms == ["Quuxley", "Zorblax"]
59
+
60
+ def test_repeats_outrank_one_offs(self):
61
+ terms = extract_terms(["", "Zorblax once. Quuxley, Quuxley, Quuxley."])
62
+ assert terms[0] == "Quuxley"
63
+
64
+ def test_capped(self):
65
+ text = " ".join(f"Zorb{chr(97 + i)}lax" for i in range(20))
66
+ assert len(extract_terms(["", text], max_terms=5)) == 5
67
+
68
+ def test_huge_selection_is_cheap(self):
69
+ text = "the quick brown fox Zorblax jumps " * 20000
70
+ t0 = time.monotonic()
71
+ assert extract_terms(["", text]) == ["Zorblax"]
72
+ assert time.monotonic() - t0 < 1.0
73
+
74
+ def test_empty(self):
75
+ assert extract_terms(["", ""]) == []
76
+
77
+
78
+ # ── budget share in the hotwords ──────────────────────────────────────────
79
+
80
+
81
+ def _provider(terms: list[str]) -> BiasProvider:
82
+ from voiceio import config
83
+ from voiceio.vocabulary import VocabularyLoader
84
+ config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
85
+ (config.CONFIG_DIR / "vocabulary.txt").write_text("\n".join(terms) + "\n")
86
+ return BiasProvider(VocabularyLoader(config.ModelConfig()))
87
+
88
+
89
+ def _vocab(n: int) -> list[str]:
90
+ # Distinct pronounceable made-up names, in file (= rank) order.
91
+ syll = ["ka", "zor", "bli", "mun", "tep", "vas", "quo", "rin", "lud", "fey"]
92
+ return [(syll[i % 10] + syll[(i // 10) % 10] + syll[(i * 7) % 10]).capitalize() + "x"
93
+ for i in range(n)]
94
+
95
+
96
+ class TestBudget:
97
+ def test_no_screen_terms_is_unchanged(self):
98
+ bp = _provider(_vocab(60))
99
+ assert bp.bias_for(HOTWORDS).hotwords == bp.bias_for(HOTWORDS, []).hotwords
100
+
101
+ def test_screen_terms_displace_only_the_vocabulary_tail(self):
102
+ vocab = _vocab(60)
103
+ bp = _provider(vocab)
104
+ base = bp.bias_for(HOTWORDS).hotwords.split(", ")
105
+ merged = bp.bias_for(HOTWORDS, ["Quuxley", "Wibblecorp"]).hotwords.split(", ")
106
+ assert "Quuxley" in merged and "Wibblecorp" in merged
107
+ kept = [t for t in merged if t in vocab]
108
+ assert kept == base[:len(kept)], "the best-ranked terms survive, in order"
109
+ assert len(kept) < len(base)
110
+ assert count_tokens(", ".join(merged), "small") <= 120 + 2 # join slack
111
+
112
+ def test_screen_share_is_capped(self):
113
+ bp = _provider(_vocab(60))
114
+ many = [f"Zorb{chr(97 + i)}laxian" for i in range(20)]
115
+ merged = bp.bias_for(HOTWORDS, many).hotwords.split(", ")
116
+ screen = [t for t in merged if t in many]
117
+ assert 0 < len(screen) < len(many)
118
+ assert count_tokens(", ".join(screen), "small") <= SCREEN_TOKEN_SHARE
119
+ # The vocabulary keeps the rest of the budget.
120
+ assert len(merged) - len(screen) >= 10
121
+
122
+ def test_screen_terms_skip_what_hotwords_carry(self):
123
+ bp = _provider(["Kalshi", "HyperNEAT"])
124
+ assert bp.screen_terms(["Kalshi - Discord", "Quuxley and HyperNEAT"], HOTWORDS) == ["Quuxley"]
125
+
126
+ def test_unbounded_appends(self):
127
+ bp = _provider(_vocab(60))
128
+ caps = Capabilities(bias=BiasMode.UNBOUNDED)
129
+ merged = bp.bias_for(caps, ["Quuxley"]).hotwords.split(", ")
130
+ assert merged[-1] == "Quuxley" and len(merged) == 61
131
+
132
+ def test_decoder_without_hotwords_gets_nothing(self):
133
+ bp = _provider(["Kalshi"])
134
+ assert bp.screen_terms(["Quuxley"], Capabilities(bias=BiasMode.PROMPT)) == []
135
+
136
+ def test_extraction_failure_degrades_to_nothing(self):
137
+ bp = _provider(["Kalshi"])
138
+ with patch("voiceio.context_terms.extract_terms", side_effect=RuntimeError):
139
+ assert bp.screen_terms(["Quuxley"], HOTWORDS) == []
140
+
141
+
142
+ # ── capture: deadline and degrade ─────────────────────────────────────────
143
+
144
+
145
+ class TestCapture:
146
+ def test_slow_source_is_dropped_at_the_deadline(self):
147
+ gate = threading.Event()
148
+
149
+ def slow(_t):
150
+ gate.wait(5)
151
+ return "Zorblax"
152
+
153
+ t0 = time.monotonic()
154
+ ctx = capture(0.1, readers={"title": lambda _t: "Kalshi", "selection": slow})
155
+ elapsed = time.monotonic() - t0
156
+ gate.set()
157
+ assert elapsed < 0.5
158
+ assert ctx.title == "Kalshi" and ctx.selection == ""
159
+
160
+ def test_failing_source_degrades_silently(self):
161
+ def boom(_t):
162
+ raise OSError("no display")
163
+ ctx = capture(0.1, readers={"title": boom, "selection": lambda _t: "x"})
164
+ assert ctx == ScreenContext(title="", selection="x", secs=ctx.secs)
165
+
166
+ def test_no_tools_no_selection(self, monkeypatch):
167
+ monkeypatch.setattr(context_terms.shutil, "which", lambda _n: None)
168
+ assert context_terms._read_primary(0.1) == ""
169
+
170
+ def test_gnome_wayland_never_uses_wl_paste(self, monkeypatch):
171
+ """wl-paste can take focus on GNOME and drop the IBus preedit."""
172
+ from types import SimpleNamespace
173
+ monkeypatch.delenv("DISPLAY", raising=False)
174
+ monkeypatch.setattr("voiceio.platform.detect",
175
+ lambda: SimpleNamespace(is_wayland=True, desktop="gnome"))
176
+ monkeypatch.setattr(context_terms.shutil, "which", lambda n: f"/usr/bin/{n}")
177
+ with patch.object(context_terms.subprocess, "run") as run:
178
+ assert context_terms._read_primary(0.1) == ""
179
+ run.assert_not_called()
180
+
181
+ def test_selection_read_has_the_deadline_as_timeout(self, monkeypatch):
182
+ monkeypatch.setenv("DISPLAY", ":0")
183
+ monkeypatch.setattr(context_terms.shutil, "which", lambda n: f"/usr/bin/{n}")
184
+ with patch.object(context_terms.subprocess, "run") as run:
185
+ run.return_value.returncode = 0
186
+ run.return_value.stdout = "Zorblax".encode()
187
+ assert context_terms._read_primary(0.15) == "Zorblax"
188
+ assert run.call_args.kwargs["timeout"] == 0.15
189
+ assert run.call_args.args[0][:2] == ["xclip", "-o"]
190
+
191
+
192
+ # ── the daemon: never on the hotkey path ──────────────────────────────────
193
+
194
+
195
+ @pytest.fixture
196
+ def vio():
197
+ from test_app_wiring import _make_vio
198
+ from fakes import FakeDecoder
199
+ dec = FakeDecoder()
200
+ dec.transcribe.return_value = "hello"
201
+ v, _, _ = _make_vio(dec)
202
+ v.cfg.model.screen_vocabulary = True
203
+ yield v, dec
204
+ if v._session is not None:
205
+ v._session.cancel()
206
+
207
+
208
+ class TestDaemon:
209
+ def test_capture_does_not_block_recording_start(self, vio):
210
+ v, dec = vio
211
+ gate = threading.Event()
212
+ captured = threading.Event()
213
+
214
+ def blocked_capture(*_a, **_k):
215
+ gate.wait(5)
216
+ captured.set()
217
+ return ScreenContext(title="Quuxley - Discord")
218
+
219
+ with patch("voiceio.context_terms.capture", side_effect=blocked_capture):
220
+ t0 = time.monotonic()
221
+ v.on_hotkey()
222
+ assert time.monotonic() - t0 < 1.0
223
+ assert v.recorder.is_recording, "recording started while capture blocks"
224
+ assert "Quuxley" not in (v._recording_bias.hotwords or "")
225
+ gate.set()
226
+ assert captured.wait(5)
227
+ for _ in range(100):
228
+ if "Quuxley" in (v._recording_bias.hotwords or ""):
229
+ break
230
+ time.sleep(0.02)
231
+ assert "Quuxley" in (v._recording_bias.hotwords or "")
232
+ assert "Quuxley" in (v._session._bias.hotwords or ""), "applied to later passes"
233
+
234
+ def test_late_capture_never_touches_the_next_recording(self, vio):
235
+ v, _ = vio
236
+ gate = threading.Event()
237
+ with patch("voiceio.context_terms.capture",
238
+ side_effect=lambda *a, **k: (gate.wait(5), ScreenContext(title="Quuxley"))[1]):
239
+ v.on_hotkey()
240
+ rid = v._recording
241
+ v._recording += 1 # as if this recording ended and another began
242
+ gate.set()
243
+ time.sleep(0.3)
244
+ v._recording = rid
245
+ assert "Quuxley" not in (v._recording_bias.hotwords or "")
246
+
247
+ def test_off_reads_nothing(self, vio):
248
+ v, _ = vio
249
+ v.cfg.model.screen_vocabulary = False
250
+ with patch("voiceio.context_terms.capture") as cap:
251
+ v.on_hotkey()
252
+ time.sleep(0.1)
253
+ cap.assert_not_called()
@@ -0,0 +1 @@
1
+ __version__ = "1.9.0"
@@ -168,6 +168,10 @@ class VoiceIO:
168
168
  threading.Thread(
169
169
  target=retention.prune, args=(cfg.data,), daemon=True,
170
170
  ).start()
171
+ if cfg.model.screen_vocabulary:
172
+ # Word frequency tables load in ~1s; not on the first dictation.
173
+ from voiceio.context_terms import warm
174
+ threading.Thread(target=warm, args=(cfg.model.language,), daemon=True).start()
171
175
 
172
176
  # Hotkey deduplication
173
177
  self._hotkey_lock = threading.Lock()
@@ -557,10 +561,12 @@ class VoiceIO:
557
561
  # `voiceio vocab`/`corrections` apply from the next recording.
558
562
  bias = self.engine.bias()
559
563
  self._recording_bias = bias
560
- if self.cfg.data.capture_context:
561
- # Snapshot the dictation target now — focus may change by finalize
564
+ if self.cfg.data.capture_context or self.cfg.model.screen_vocabulary:
565
+ # Snapshot the dictation target now — focus may change by finalize.
566
+ # Off this thread: the chime and the mic never wait on it.
562
567
  self._context_title = None
563
- threading.Thread(target=self._capture_context, daemon=True).start()
568
+ threading.Thread(target=self._capture_context, args=(self._recording,),
569
+ daemon=True, name="screen-context").start()
564
570
  self._record_start = time.monotonic()
565
571
  self.recorder.start()
566
572
  self.recorder.set_on_auto_stop(self._on_auto_stop)
@@ -627,9 +633,29 @@ class VoiceIO:
627
633
  # No finalizer will run to release the input source, so do it here.
628
634
  self._typers.end(rid)
629
635
 
630
- def _capture_context(self) -> None:
636
+ def _capture_context(self, rid: int) -> None:
631
637
  from voiceio import retention
632
- self._context_title = retention.active_window_title()
638
+ if not self.cfg.model.screen_vocabulary:
639
+ self._context_title = retention.active_window_title()
640
+ return
641
+ from voiceio import context_terms
642
+ t0 = time.monotonic()
643
+ screen = context_terms.capture()
644
+ if self.cfg.data.capture_context:
645
+ self._context_title = screen.title or None
646
+ terms = self.engine.screen_terms([screen.title, screen.selection])
647
+ if not terms:
648
+ return
649
+ bias = self.engine.bias(screen_terms=terms)
650
+ with self._hotkey_lock:
651
+ if self._recording != rid or self._state != _State.RECORDING:
652
+ return
653
+ self._recording_bias = bias
654
+ if self._session is not None:
655
+ self._session.set_bias(bias)
656
+ # A count only: these are words from the user's screen.
657
+ log.debug("Screen vocabulary: %d term(s) from %s, ready %.0f ms after start",
658
+ len(terms), "+".join(screen.sources()), (time.monotonic() - t0) * 1000)
633
659
 
634
660
  def _retention_extra(self, audio: np.ndarray | None) -> dict:
635
661
  """Save the utterance audio (before transcription, so it survives
@@ -90,6 +90,10 @@ class ModelConfig:
90
90
  # "modified_beam_search" (required for hotwords).
91
91
  decoding: str = ""
92
92
  vocabulary_file: str = "" # path to vocabulary.txt
93
+ # Bias each dictation toward rare names on screen: the focused window's
94
+ # title and the selected text (voiceio/context_terms.py). Read locally
95
+ # when recording starts; never stored or sent anywhere.
96
+ screen_vocabulary: bool = True
93
97
  # Beam width of the passes whose text you keep (freezes and the final
94
98
  # tail); interim previews are always greedy. 1 is greedy: faster stop.
95
99
  beam_size: int = 5
@@ -0,0 +1,253 @@
1
+ """On-screen vocabulary: bias one dictation toward the names you can see.
2
+
3
+ Most misrecognitions are proper nouns and jargon, and the word you are about
4
+ to say is often already on screen: in the focused window's title or in the
5
+ text you just selected. This reads those two sources when a recording starts,
6
+ picks out the terms Whisper is likely to miss (rare words, CamelCase,
7
+ acronyms, multiword names) and hands them to `core.bias` for this one
8
+ recording, inside a small share of the hotword budget.
9
+
10
+ Capture happens off the hotkey path, under a hard deadline, and degrades to
11
+ "no terms" on any failure. Nothing captured here is logged or written to
12
+ disk; only a term count is logged.
13
+
14
+ Not used: AT-SPI (the focused widget's text). It needs PyGObject, which the
15
+ venv can't import, so a system-Python helper; a one-shot walk of the
16
+ accessibility tree took 120-900 ms on GNOME, and Chromium/Electron apps expose
17
+ their text only while a screen reader is on.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import threading
27
+ import time
28
+ from collections.abc import Callable, Iterable
29
+ from dataclasses import dataclass
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ # Capture deadline. Long enough for two ~20 ms subprocess reads under load,
34
+ # short enough that the terms are in place before the first interim pass.
35
+ CAPTURE_TIMEOUT_SECS = 0.15
36
+ # Only the head of a large selection is scanned: extraction stays ~10 ms.
37
+ MAX_SCAN_CHARS = 8000
38
+ MAX_TERMS = 8
39
+
40
+ # Zipf thresholds (see wordfreq.py: 2.0 ≈ top 30K words). A capitalized word
41
+ # is a name, and Whisper misses names it has seen far more often than generic
42
+ # rare words (Kosh 2.1, Wayland 2.7), so names get a higher bar.
43
+ _ZIPF_LOWER = 2.0
44
+ _ZIPF_NAME = 3.0
45
+ _ZIPF_MIXED = 3.5
46
+ _ZIPF_COMMON = 4.0
47
+
48
+ _TOKEN = re.compile(r"[\w'’.\-/@#:+]+")
49
+ _WORD = re.compile(r"^[A-Za-zÀ-ɏ][A-Za-zÀ-ɏ'’\-]*[A-Za-zÀ-ɏ]$")
50
+ _MIXED = re.compile(r"[a-zà-ÿ][A-ZÀ-Þ]|[A-ZÀ-Þ]{2}[a-zà-ÿ]")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ScreenContext:
55
+ title: str = ""
56
+ selection: str = ""
57
+ secs: float = 0.0
58
+
59
+ def sources(self) -> list[str]:
60
+ return [n for n, v in (("title", self.title), ("selection", self.selection)) if v]
61
+
62
+
63
+ def _kind(word: str, zipf: Callable[[str], float]) -> int:
64
+ """How much a word looks like something Whisper will miss: 3 = CamelCase
65
+ or rare name, 2 = acronym, 1 = rare lowercase word, 0 = not a term."""
66
+ if not _WORD.match(word) or len(word) < 3:
67
+ return 0
68
+ if _MIXED.search(word):
69
+ return 3 if len(word) <= 20 and zipf(word) < _ZIPF_MIXED else 0
70
+ if word.isupper():
71
+ return 2 if len(word) <= 6 and zipf(word) < _ZIPF_NAME else 0
72
+ if word[0].isupper():
73
+ return 3 if zipf(word) < _ZIPF_NAME else 0
74
+ return 1 if len(word) >= 4 and zipf(word) < _ZIPF_LOWER else 0
75
+
76
+
77
+ def _words(text: str) -> list[list[str]]:
78
+ """Runs of plain words; an identifier, URL or number ends a run, and its
79
+ letter parts (voiceio.dev → voiceio) stand alone."""
80
+ runs: list[list[str]] = [[]]
81
+ for line in text.splitlines():
82
+ runs.append([])
83
+ for tok in _TOKEN.findall(line):
84
+ stripped = tok.strip("'’.-:")
85
+ if _WORD.match(stripped):
86
+ runs[-1].append(stripped)
87
+ if tok.rstrip("'’") != stripped: # trailing punctuation ends the run
88
+ runs.append([])
89
+ continue
90
+ runs.append([])
91
+ for part in re.split(r"[^A-Za-zÀ-ɏ'’]+", tok):
92
+ if _WORD.match(part):
93
+ runs[-1].append(part)
94
+ runs.append([])
95
+ return [r for r in runs if r]
96
+
97
+
98
+ def _name_end(run: list[str], kinds: list[int], i: int,
99
+ zipf: Callable[[str], float]) -> int:
100
+ """End of a multiword name starting at run[i] (2-3 capitalized words,
101
+ one of them rare), or i when there is none. Very common words don't
102
+ start or end a name: "Hey Kosh" is a greeting, not a name."""
103
+ j = i
104
+ while j < len(run) and j - i < 3 and run[j][0].isupper() and not run[j].isupper():
105
+ j += 1
106
+ if j - i < 2 or zipf(run[i]) >= _ZIPF_COMMON or run[i] == run[i + 1]:
107
+ return i
108
+ while j - i >= 2 and zipf(run[j - 1]) >= _ZIPF_COMMON:
109
+ j -= 1
110
+ return j if j - i >= 2 and any(k == 3 for k in kinds[i:j]) else i
111
+
112
+
113
+ def extract_terms(
114
+ texts: Iterable[str],
115
+ *,
116
+ language: str = "en",
117
+ exclude: Iterable[str] = (),
118
+ prefer: Iterable[str] = (),
119
+ max_terms: int = MAX_TERMS,
120
+ ) -> list[str]:
121
+ """Candidate hotwords from on-screen text, best first.
122
+
123
+ `texts` in priority order (title before selection). `exclude` holds terms
124
+ already biased (case-insensitive); `prefer` the user's full vocabulary,
125
+ whose long tail never fits the budget — seeing one of them on screen is
126
+ the strongest signal there is.
127
+ """
128
+ from functools import lru_cache
129
+
130
+ from voiceio.wordfreq import _zipf
131
+
132
+ zipf = lru_cache(maxsize=4096)(lambda w: _zipf(w, language))
133
+ excluded = {t.lower() for t in exclude}
134
+ preferred = {t.lower() for t in prefer}
135
+ # lowercased term → [score, first seen, spelling as first seen]
136
+ scores: dict[str, list] = {}
137
+
138
+ def add(term: str, score: float) -> None:
139
+ key = term.lower()
140
+ if key in excluded:
141
+ return
142
+ if key in scores:
143
+ # Repeats add a little, so the term the page is about outranks a
144
+ # one-off, without letting a long page flood the list.
145
+ scores[key][0] += 0.5
146
+ return
147
+ scores[key] = [score + (3 if key in preferred else 0), len(scores), term]
148
+
149
+ texts = list(texts)
150
+ for rank, text in enumerate(texts):
151
+ weight = (len(texts) - rank) * 0.1 # earlier source wins ties
152
+ for run in _words((text or "")[:MAX_SCAN_CHARS]):
153
+ kinds = [_kind(w, zipf) for w in run]
154
+ i = 0
155
+ while i < len(run):
156
+ j = _name_end(run, kinds, i, zipf)
157
+ if j > i + 1:
158
+ add(" ".join(run[i:j]), 3 + weight)
159
+ i = j
160
+ continue
161
+ if kinds[i]:
162
+ add(run[i], kinds[i] + weight)
163
+ i += 1
164
+
165
+ ranked = sorted(scores.values(), key=lambda v: (-v[0], v[1]))
166
+ out: list[str] = []
167
+ seen_words: set[str] = set()
168
+ for _, _, term in ranked:
169
+ parts = term.lower().split()
170
+ if seen_words.intersection(parts):
171
+ continue # a name and its parts cost tokens twice for one word
172
+ out.append(term)
173
+ seen_words.update(parts)
174
+ if len(out) >= max_terms:
175
+ break
176
+ return out
177
+
178
+
179
+ def warm(language: str = "en") -> None:
180
+ """Load the word frequency tables now, not on the first dictation."""
181
+ extract_terms(["Warmup"], language=language)
182
+
183
+
184
+ # ── capture ────────────────────────────────────────────────────────────────
185
+
186
+
187
+ def _read_primary(timeout: float) -> str:
188
+ """The PRIMARY selection (highlighted text). xclip through XWayland first:
189
+ on GNOME, wl-paste can take keyboard focus for a frame, which drops an
190
+ IBus preedit — never acceptable at the start of a dictation."""
191
+ from voiceio.platform import detect
192
+ p = detect()
193
+ if os.environ.get("DISPLAY") and shutil.which("xclip"):
194
+ argv = ["xclip", "-o", "-selection", "primary"]
195
+ elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
196
+ argv = ["wl-paste", "-p", "--no-newline"]
197
+ else:
198
+ return ""
199
+ try:
200
+ out = subprocess.run(argv, capture_output=True, timeout=timeout)
201
+ except (subprocess.TimeoutExpired, OSError):
202
+ return ""
203
+ if out.returncode != 0:
204
+ return ""
205
+ return out.stdout[:MAX_SCAN_CHARS * 4].decode("utf-8", "replace")
206
+
207
+
208
+ def _read_title(_timeout: float) -> str:
209
+ from voiceio import retention
210
+ return retention.active_window_title() or ""
211
+
212
+
213
+ def capture(timeout: float = CAPTURE_TIMEOUT_SECS, *,
214
+ readers: dict[str, Callable[[float], str]] | None = None) -> ScreenContext:
215
+ """Read the window title and selection in parallel; whatever isn't back
216
+ by `timeout` is dropped (its thread finishes on its own). Never raises."""
217
+ readers = readers or {"title": _read_title, "selection": _read_primary}
218
+ t0 = time.monotonic()
219
+ results: dict[str, str] = {}
220
+
221
+ def run(name: str, fn: Callable[[float], str]) -> None:
222
+ try:
223
+ results[name] = fn(timeout) or ""
224
+ except Exception: # noqa: BLE001 — context is a bonus
225
+ log.debug("screen context: %s failed", name, exc_info=True)
226
+
227
+ threads = [threading.Thread(target=run, args=item, daemon=True,
228
+ name=f"screen-{item[0]}") for item in readers.items()]
229
+ for t in threads:
230
+ t.start()
231
+ deadline = t0 + timeout
232
+ for t in threads:
233
+ t.join(max(0.0, deadline - time.monotonic()))
234
+ got = dict(results) # snapshot: a late reader must not change it
235
+ return ScreenContext(title=got.get("title", ""), selection=got.get("selection", ""),
236
+ secs=time.monotonic() - t0)
237
+
238
+
239
+ def describe() -> str:
240
+ """For `voiceio doctor`: which sources this session can read."""
241
+ from voiceio.focus import focused_window
242
+ from voiceio.platform import detect
243
+ p = detect()
244
+ sources = []
245
+ if os.environ.get("DISPLAY") and shutil.which("xclip"):
246
+ sources.append("selection (xclip)")
247
+ elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
248
+ sources.append("selection (wl-paste)")
249
+ if focused_window(xwayland=True) is not None:
250
+ sources.append("window title")
251
+ elif p.is_wayland and p.desktop in ("gnome", "kde"):
252
+ sources.append("window title of X11 apps only")
253
+ return ", ".join(sources) or "no source available"