python-voiceio 1.13.0__tar.gz → 1.14.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 (232) hide show
  1. {python_voiceio-1.13.0/python_voiceio.egg-info → python_voiceio-1.14.0}/PKG-INFO +10 -1
  2. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/README.md +9 -0
  3. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/pyproject.toml +1 -1
  4. {python_voiceio-1.13.0 → python_voiceio-1.14.0/python_voiceio.egg-info}/PKG-INFO +10 -1
  5. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/python_voiceio.egg-info/SOURCES.txt +4 -0
  6. python_voiceio-1.14.0/tests/test_announce.py +84 -0
  7. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_app_wiring.py +1 -1
  8. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_report.py +19 -3
  9. python_voiceio-1.14.0/tests/test_spool.py +87 -0
  10. python_voiceio-1.14.0/voiceio/__init__.py +1 -0
  11. python_voiceio-1.14.0/voiceio/announce.py +106 -0
  12. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/app.py +73 -9
  13. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/__init__.py +25 -0
  14. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/feedback.py +4 -0
  15. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/socket_backend.py +11 -1
  16. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/recorder.py +11 -2
  17. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/report.py +13 -0
  18. python_voiceio-1.14.0/voiceio/spool.py +116 -0
  19. python_voiceio-1.13.0/voiceio/__init__.py +0 -1
  20. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/LICENSE +0 -0
  21. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  22. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  23. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/python_voiceio.egg-info/requires.txt +0 -0
  24. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/python_voiceio.egg-info/top_level.txt +0 -0
  25. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/setup.cfg +0 -0
  26. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_audio_quality.py +0 -0
  27. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_audio_source.py +0 -0
  28. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_backend_probes.py +0 -0
  29. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_cancel.py +0 -0
  30. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_cli.py +0 -0
  31. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_clipboard_read.py +0 -0
  32. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_clipboard_typer.py +0 -0
  33. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_commands.py +0 -0
  34. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_concurrency_lockdown.py +0 -0
  35. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_config.py +0 -0
  36. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_context_terms.py +0 -0
  37. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_corrections.py +0 -0
  38. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_dead_mic.py +0 -0
  39. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_decoders.py +0 -0
  40. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_engine.py +0 -0
  41. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_evdev_uaccess.py +0 -0
  42. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_fallback.py +0 -0
  43. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_fcitx_bridge.py +0 -0
  44. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_feedback_stream.py +0 -0
  45. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_focus.py +0 -0
  46. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_health.py +0 -0
  47. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_hints.py +0 -0
  48. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_history.py +0 -0
  49. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_hotkey_modes.py +0 -0
  50. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_engine_limits.py +0 -0
  51. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_engine_listener.py +0 -0
  52. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_pending.py +0 -0
  53. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_pids.py +0 -0
  54. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_ping.py +0 -0
  55. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_session.py +0 -0
  56. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_textlimit.py +0 -0
  57. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_ibus_typer.py +0 -0
  58. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_idle_audio.py +0 -0
  59. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_clean.py +0 -0
  60. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_import.py +0 -0
  61. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_language.py +0 -0
  62. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_metrics.py +0 -0
  63. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_pipeline.py +0 -0
  64. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_quiet.py +0 -0
  65. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_resume.py +0 -0
  66. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_review.py +0 -0
  67. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_learn_targets.py +0 -0
  68. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_llm.py +0 -0
  69. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_llm_api.py +0 -0
  70. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_mic_gain.py +0 -0
  71. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_modifier_guard.py +0 -0
  72. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_numbers.py +0 -0
  73. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_paste_last.py +0 -0
  74. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_pipeline.py +0 -0
  75. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_platform.py +0 -0
  76. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_postcorrect.py +0 -0
  77. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_postprocess.py +0 -0
  78. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_prebuffer.py +0 -0
  79. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_presence.py +0 -0
  80. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_prompt.py +0 -0
  81. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_quiet_audio.py +0 -0
  82. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_recorder_integration.py +0 -0
  83. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_retention.py +0 -0
  84. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_robustness.py +0 -0
  85. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_security_hardening.py +0 -0
  86. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_server.py +0 -0
  87. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_service.py +0 -0
  88. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_setup.py +0 -0
  89. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_sherpa.py +0 -0
  90. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_streaming.py +0 -0
  91. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_suggest.py +0 -0
  92. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_tokens.py +0 -0
  93. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_transcriber.py +0 -0
  94. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_tts.py +0 -0
  95. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_typer_manager.py +0 -0
  96. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_vad.py +0 -0
  97. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_vocabulary.py +0 -0
  98. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_wordfreq.py +0 -0
  99. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/tests/test_worker_decode.py +0 -0
  100. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/__main__.py +0 -0
  101. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/audio_source.py +0 -0
  102. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/backends.py +0 -0
  103. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/configcmd.py +0 -0
  104. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/corrections.py +0 -0
  105. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/doctor.py +0 -0
  106. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/history.py +0 -0
  107. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/learn.py +0 -0
  108. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/models.py +0 -0
  109. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/servicecmd.py +0 -0
  110. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/uninstall.py +0 -0
  111. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/cli/vocab.py +0 -0
  112. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/clipboard_read.py +0 -0
  113. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/commands.py +0 -0
  114. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/config.py +0 -0
  115. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/consent.py +0 -0
  116. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/context_terms.py +0 -0
  117. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/__init__.py +0 -0
  118. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/bias.py +0 -0
  119. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/__init__.py +0 -0
  120. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/base.py +0 -0
  121. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/catalog.py +0 -0
  122. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  123. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  124. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/fetch.py +0 -0
  125. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/lanes.py +0 -0
  126. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/sherpa.py +0 -0
  127. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/decoders/whispercpp.py +0 -0
  128. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/engine.py +0 -0
  129. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/core/pipeline.py +0 -0
  130. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/corrections.py +0 -0
  131. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  132. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/data/__init__.py +0 -0
  133. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/demo.py +0 -0
  134. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/display.py +0 -0
  135. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/focus.py +0 -0
  136. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/health.py +0 -0
  137. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hints.py +0 -0
  138. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/history.py +0 -0
  139. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/__init__.py +0 -0
  140. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/base.py +0 -0
  141. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/chain.py +0 -0
  142. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/evdev.py +0 -0
  143. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  144. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/__init__.py +0 -0
  145. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/engine.py +0 -0
  146. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/install.py +0 -0
  147. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/pending.py +0 -0
  148. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/session.py +0 -0
  149. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ibus/textlimit.py +0 -0
  150. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/idle_audio.py +0 -0
  151. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/__init__.py +0 -0
  152. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/augment.py +0 -0
  153. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/clean.py +0 -0
  154. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/evaluate.py +0 -0
  155. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/importer.py +0 -0
  156. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/metrics.py +0 -0
  157. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/quiet.py +0 -0
  158. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/review.py +0 -0
  159. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/store.py +0 -0
  160. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/teacher.py +0 -0
  161. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/learn/train.py +0 -0
  162. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/llm.py +0 -0
  163. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/llm_api.py +0 -0
  164. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/mic_gain.py +0 -0
  165. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/models/__init__.py +0 -0
  166. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/models/silero_vad.onnx +0 -0
  167. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/numbers.py +0 -0
  168. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/pidlock.py +0 -0
  169. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/platform.py +0 -0
  170. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/postcorrect.py +0 -0
  171. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/postprocess.py +0 -0
  172. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/power.py +0 -0
  173. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/presence.py +0 -0
  174. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/privacy.py +0 -0
  175. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/prompt.py +0 -0
  176. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/quiet_audio.py +0 -0
  177. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/retention.py +0 -0
  178. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/server/__init__.py +0 -0
  179. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/server/__main__.py +0 -0
  180. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/server/app.py +0 -0
  181. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/server/config.py +0 -0
  182. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/server/runtime.py +0 -0
  183. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/service.py +0 -0
  184. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/__init__.py +0 -0
  185. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/configfile.py +0 -0
  186. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/extras.py +0 -0
  187. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/hotkey.py +0 -0
  188. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/noninteractive.py +0 -0
  189. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/speech.py +0 -0
  190. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/steps.py +0 -0
  191. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/system.py +0 -0
  192. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/setup/ui.py +0 -0
  193. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/sounds/__init__.py +0 -0
  194. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/sounds/alert.wav +0 -0
  195. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/sounds/commit.wav +0 -0
  196. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/sounds/start.wav +0 -0
  197. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/sounds/stop.wav +0 -0
  198. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/streaming.py +0 -0
  199. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/suggest.py +0 -0
  200. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tokens.py +0 -0
  201. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tray/__init__.py +0 -0
  202. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tray/_icons.py +0 -0
  203. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tray/_indicator.py +0 -0
  204. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tray/_pystray.py +0 -0
  205. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/__init__.py +0 -0
  206. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/base.py +0 -0
  207. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/chain.py +0 -0
  208. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/edge_engine.py +0 -0
  209. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/espeak.py +0 -0
  210. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/piper_engine.py +0 -0
  211. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/player.py +0 -0
  212. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/tts/supertonic_engine.py +0 -0
  213. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/__init__.py +0 -0
  214. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/base.py +0 -0
  215. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/chain.py +0 -0
  216. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/clipboard.py +0 -0
  217. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/collecting.py +0 -0
  218. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/fcitx_bridge.py +0 -0
  219. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/ibus.py +0 -0
  220. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/manager.py +0 -0
  221. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/modifier_guard.py +0 -0
  222. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/paste.py +0 -0
  223. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/pynput_type.py +0 -0
  224. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/wtype.py +0 -0
  225. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/xdotool.py +0 -0
  226. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/typers/ydotool.py +0 -0
  227. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/ui.py +0 -0
  228. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/vad.py +0 -0
  229. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/vocab_stats.py +0 -0
  230. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/vocabulary.py +0 -0
  231. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/watchdog.py +0 -0
  232. {python_voiceio-1.13.0 → python_voiceio-1.14.0}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.13.0
3
+ Version: 1.14.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -109,6 +109,15 @@ voiceio learn status # what it learned and how each model score
109
109
 
110
110
  Scheduled learning runs daily or weekly at a time you pick, on mains power and only while you're away from the computer: it stops within seconds when you come back and resumes later where it was. You get a notification with a button to stop it. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
111
111
 
112
+ ## Let your agents talk to you
113
+
114
+ ```bash
115
+ voiceio say --from build-bot "tests are green" # chime, notification, then it's read aloud
116
+ voiceio say --from deploy --urgent "I need your approval"
117
+ ```
118
+
119
+ Running several coding agents? Have each one call `voiceio say` when it needs you, so you hear *which* one. It never talks over you: while you dictate, announcements wait (speech into an open mic would get typed). They are read in your [tts] voice, offline.
120
+
112
121
  ## Configure
113
122
 
114
123
  Everything lives in `~/.config/voiceio/config.toml`; [config.example.toml](config.example.toml) documents every option. `voiceio config set section.key value` edits it in place.
@@ -56,6 +56,15 @@ voiceio learn status # what it learned and how each model score
56
56
 
57
57
  Scheduled learning runs daily or weekly at a time you pick, on mains power and only while you're away from the computer: it stops within seconds when you come back and resumes later where it was. You get a notification with a button to stop it. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
58
58
 
59
+ ## Let your agents talk to you
60
+
61
+ ```bash
62
+ voiceio say --from build-bot "tests are green" # chime, notification, then it's read aloud
63
+ voiceio say --from deploy --urgent "I need your approval"
64
+ ```
65
+
66
+ Running several coding agents? Have each one call `voiceio say` when it needs you, so you hear *which* one. It never talks over you: while you dictate, announcements wait (speech into an open mic would get typed). They are read in your [tts] voice, offline.
67
+
59
68
  ## Configure
60
69
 
61
70
  Everything lives in `~/.config/voiceio/config.toml`; [config.example.toml](config.example.toml) documents every option. `voiceio config set section.key value` edits it in place.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.13.0"
7
+ version = "1.14.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.13.0
3
+ Version: 1.14.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -109,6 +109,15 @@ voiceio learn status # what it learned and how each model score
109
109
 
110
110
  Scheduled learning runs daily or weekly at a time you pick, on mains power and only while you're away from the computer: it stops within seconds when you come back and resumes later where it was. You get a notification with a button to stop it. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
111
111
 
112
+ ## Let your agents talk to you
113
+
114
+ ```bash
115
+ voiceio say --from build-bot "tests are green" # chime, notification, then it's read aloud
116
+ voiceio say --from deploy --urgent "I need your approval"
117
+ ```
118
+
119
+ Running several coding agents? Have each one call `voiceio say` when it needs you, so you hear *which* one. It never talks over you: while you dictate, announcements wait (speech into an open mic would get typed). They are read in your [tts] voice, offline.
120
+
112
121
  ## Configure
113
122
 
114
123
  Everything lives in `~/.config/voiceio/config.toml`; [config.example.toml](config.example.toml) documents every option. `voiceio config set section.key value` edits it in place.
@@ -7,6 +7,7 @@ python_voiceio.egg-info/dependency_links.txt
7
7
  python_voiceio.egg-info/entry_points.txt
8
8
  python_voiceio.egg-info/requires.txt
9
9
  python_voiceio.egg-info/top_level.txt
10
+ tests/test_announce.py
10
11
  tests/test_app_wiring.py
11
12
  tests/test_audio_quality.py
12
13
  tests/test_audio_source.py
@@ -73,6 +74,7 @@ tests/test_server.py
73
74
  tests/test_service.py
74
75
  tests/test_setup.py
75
76
  tests/test_sherpa.py
77
+ tests/test_spool.py
76
78
  tests/test_streaming.py
77
79
  tests/test_suggest.py
78
80
  tests/test_tokens.py
@@ -85,6 +87,7 @@ tests/test_wordfreq.py
85
87
  tests/test_worker_decode.py
86
88
  voiceio/__init__.py
87
89
  voiceio/__main__.py
90
+ voiceio/announce.py
88
91
  voiceio/app.py
89
92
  voiceio/audio_source.py
90
93
  voiceio/backends.py
@@ -119,6 +122,7 @@ voiceio/recorder.py
119
122
  voiceio/report.py
120
123
  voiceio/retention.py
121
124
  voiceio/service.py
125
+ voiceio/spool.py
122
126
  voiceio/streaming.py
123
127
  voiceio/suggest.py
124
128
  voiceio/tokens.py
@@ -0,0 +1,84 @@
1
+ """`voiceio say`: the message format, the queue, the socket and the CLI."""
2
+ import threading
3
+ import time
4
+ from unittest.mock import patch
5
+
6
+ from voiceio import announce
7
+ from voiceio.announce import Message
8
+
9
+
10
+ def test_encode_parse_round_trip():
11
+ msg = Message("tests are green", "build bot", urgent=True)
12
+ cmd = announce.encode(msg)
13
+ assert cmd.startswith("say:")
14
+ assert announce.parse(cmd[len("say:"):]) == msg
15
+ assert msg.spoken == "build bot: tests are green"
16
+ assert Message("hi").spoken == "hi"
17
+
18
+
19
+ def test_parse_rejects_junk_and_caps_length():
20
+ assert announce.parse("not json") is None
21
+ assert announce.parse('["a list"]') is None
22
+ assert announce.parse('{"text": " "}') is None
23
+ long = announce.parse('{"text": "%s", "from": "x\\n y"}' % ("a" * 5000))
24
+ assert len(long.text) == announce.MAX_TEXT and long.sender == "x y"
25
+
26
+
27
+ def test_announcer_holds_while_busy_then_speaks_in_order():
28
+ spoken, busy = [], threading.Event()
29
+ busy.set()
30
+ done = threading.Event()
31
+
32
+ def speak(text):
33
+ spoken.append(text)
34
+ if len(spoken) == 2:
35
+ done.set()
36
+
37
+ with patch.object(announce, "cue_and_notify"):
38
+ a = announce.Announcer(speak=speak, busy=busy.is_set)
39
+ a.submit(Message("one", "a"))
40
+ a.submit(Message("two", "b"))
41
+ time.sleep(0.4)
42
+ assert spoken == [] # held while the user dictates
43
+ busy.clear()
44
+ assert done.wait(3)
45
+ assert spoken == ["a: one", "b: two"]
46
+
47
+
48
+ def test_announcer_drops_when_full():
49
+ hold, spoken = threading.Event(), []
50
+ with patch.object(announce, "cue_and_notify"):
51
+ a = announce.Announcer(speak=lambda t: (hold.wait(5), spoken.append(t)), busy=lambda: False)
52
+ results = [a.submit(Message(str(i))) for i in range(announce._QUEUE + 3)]
53
+ hold.set()
54
+ deadline = time.monotonic() + 3 # drain inside the patch: no real notifications
55
+ while a._queue.unfinished_tasks and len(spoken) < sum(results) and time.monotonic() < deadline:
56
+ time.sleep(0.05)
57
+ assert not all(results)
58
+
59
+
60
+ def test_socket_routes_payload_commands(tmp_path, monkeypatch):
61
+ from voiceio.hotkeys import socket_backend as sb
62
+ monkeypatch.setattr(sb, "SOCKET_PATH", tmp_path / "v.sock")
63
+ got = threading.Event()
64
+ payloads = []
65
+ hk = sb.SocketHotkey()
66
+ hk.handle_payload("say", lambda p: (payloads.append(p), got.set()))
67
+ hk.start("x", lambda: None)
68
+ try:
69
+ assert sb.send_command(announce.encode(Message("hello", "agent")))
70
+ assert got.wait(3)
71
+ finally:
72
+ hk.stop()
73
+ assert announce.parse(payloads[0]) == Message("hello", "agent")
74
+
75
+
76
+ def test_cli_speaks_here_without_a_daemon(tmp_path, monkeypatch, capsys):
77
+ import sys
78
+ from voiceio import cli
79
+ from voiceio.hotkeys import socket_backend as sb
80
+ monkeypatch.setattr(sb, "SOCKET_PATH", tmp_path / "absent.sock")
81
+ monkeypatch.setattr(sys, "argv", ["voiceio", "say", "--from", "bot", "hi", "there"])
82
+ with patch.object(announce, "say_here", return_value=True) as here:
83
+ cli.main()
84
+ here.assert_called_once_with(Message("hi there", "bot", False))
@@ -366,7 +366,7 @@ class TestStartCue:
366
366
 
367
367
  vio.recorder.stream_health = health
368
368
  real_start = vio.recorder.start
369
- vio.recorder.start = lambda: (events.append("record"), real_start())
369
+ vio.recorder.start = lambda **kw: (events.append("record"), real_start(**kw))
370
370
  vio._quiet = MagicMock()
371
371
  return events
372
372
 
@@ -121,7 +121,23 @@ class _Resp(io.BytesIO):
121
121
  return False
122
122
 
123
123
 
124
- def test_post_success_reads_receipt(monkeypatch):
124
+ @pytest.fixture
125
+ def outside_pytest(monkeypatch):
126
+ """post() refuses under pytest; these tests check what it does past that.
127
+ (pytest sets PYTEST_CURRENT_TEST again after fixtures, so patch the check.)"""
128
+ monkeypatch.setattr(report, "_test_artifact", lambda text: "")
129
+
130
+
131
+ def test_post_refuses_test_runs_and_mock_text(monkeypatch):
132
+ monkeypatch.setattr(report, "_urlopen", lambda url, timeout: pytest.fail("posted"))
133
+ ok, why = report.post("voiceio 1.13.0 report")
134
+ assert not ok and "pytest" in why
135
+ monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
136
+ ok, why = report.post("system: Ubuntu · <MagicMock name='mock.platform.desktop'>")
137
+ assert not ok and "mock" in why
138
+
139
+
140
+ def test_post_success_reads_receipt(monkeypatch, outside_pytest):
125
141
  seen = []
126
142
 
127
143
  def fake(url, timeout):
@@ -138,13 +154,13 @@ def test_post_success_reads_receipt(monkeypatch):
138
154
 
139
155
  @pytest.mark.parametrize("body", [b'{"ok": false, "error": "rate limited"}', b"[]", b"not json",
140
156
  b'{"ok": true}'])
141
- def test_post_bad_response_is_failure(monkeypatch, body):
157
+ def test_post_bad_response_is_failure(monkeypatch, outside_pytest, body):
142
158
  monkeypatch.setattr(report, "_urlopen", lambda url, timeout: _Resp(body))
143
159
  ok, why = report.post("x")
144
160
  assert not ok and why
145
161
 
146
162
 
147
- def test_post_network_error_never_raises():
163
+ def test_post_network_error_never_raises(outside_pytest):
148
164
  # conftest refuses every connection: this is the offline path.
149
165
  ok, why = report.post("x")
150
166
  assert not ok and "ConnectionRefused" in why
@@ -0,0 +1,87 @@
1
+ """The crash spool: a dictation's audio survives a killed daemon."""
2
+ import os
3
+
4
+ import numpy as np
5
+
6
+ from voiceio import spool
7
+
8
+
9
+ def _wait(s):
10
+ s.flush()
11
+
12
+
13
+ def test_recording_is_spooled_then_removed(tmp_path):
14
+ s = spool.Spool(tmp_path)
15
+ s.begin(7, 16000, prebuffer=np.ones(160, np.float32))
16
+ s.write(7, np.full((320, 1), 0.5, np.float32))
17
+ _wait(s)
18
+ path = s.path(7, 16000)
19
+ audio = np.fromfile(path, dtype=np.float32)
20
+ assert len(audio) == 480 and audio[-1] == 0.5
21
+ assert oct(path.stat().st_mode & 0o777) == "0o600"
22
+ s.finish(7)
23
+ _wait(s)
24
+ assert not path.exists()
25
+
26
+
27
+ def test_writes_for_unknown_or_finished_recordings_are_ignored(tmp_path):
28
+ s = spool.Spool(tmp_path)
29
+ s.write(1, np.ones(10, np.float32))
30
+ s.finish(1)
31
+ _wait(s)
32
+ assert list(tmp_path.iterdir()) == []
33
+
34
+
35
+ def test_orphans_are_files_of_dead_processes(tmp_path, monkeypatch):
36
+ long = np.zeros(32000, np.float32)
37
+ (tmp_path / "recording-999999-3-16000.f32").write_bytes(long.tobytes()) # dead
38
+ (tmp_path / f"recording-{os.getpid()}-4-16000.f32").write_bytes(long.tobytes()) # us
39
+ (tmp_path / "recording-999998-1-16000.f32").write_bytes(np.zeros(100, np.float32).tobytes())
40
+ (tmp_path / "notes.txt").write_text("x")
41
+ monkeypatch.setattr(spool, "_alive", lambda pid: pid != 999999 and pid != 999998)
42
+ found = spool.orphans(tmp_path)
43
+ assert [(p.name, len(a), r) for p, a, r in found] == [("recording-999999-3-16000.f32", 32000, 16000)]
44
+ assert not (tmp_path / "recording-999998-1-16000.f32").exists() # too short: dropped
45
+ assert (tmp_path / f"recording-{os.getpid()}-4-16000.f32").exists()
46
+
47
+
48
+ def test_missing_dir_has_no_orphans(tmp_path):
49
+ assert spool.orphans(tmp_path / "nope") == []
50
+
51
+
52
+ def test_recorder_taps_every_chunk_of_a_recording():
53
+ from unittest.mock import MagicMock
54
+ from voiceio.config import AudioConfig
55
+ from voiceio.recorder import AudioRecorder
56
+ rec = AudioRecorder(AudioConfig())
57
+ rec._stream = MagicMock()
58
+ rec._ring.append(np.ones((80, 1), np.float32))
59
+ got = []
60
+ rec.start(tap=got.append)
61
+ rec._callback(np.full((160, 1), 0.2, np.float32), 160, None, None)
62
+ rec.stop()
63
+ rec._callback(np.full((160, 1), 0.3, np.float32), 160, None, None) # after stop: not tapped
64
+ assert [len(c) for c in got] == [80, 160]
65
+
66
+
67
+ def test_daemon_recovers_an_orphan_into_history(tmp_path, monkeypatch):
68
+ from types import SimpleNamespace
69
+ from unittest.mock import MagicMock
70
+ from voiceio import app, feedback, history
71
+ orphan = tmp_path / "recording-999999-1-16000.f32"
72
+ orphan.write_bytes(np.zeros(32000, np.float32).tobytes())
73
+ monkeypatch.setattr(spool, "spool_dir", lambda: tmp_path)
74
+ monkeypatch.setattr(spool, "_alive", lambda pid: False)
75
+ saved, told = [], []
76
+ monkeypatch.setattr(history, "append", lambda text, **kw: saved.append((text, kw)))
77
+ monkeypatch.setattr(feedback, "notify", lambda title, body, urgent=False: told.append(body))
78
+ vio = app.VoiceIO.__new__(app.VoiceIO)
79
+ vio.recorder = SimpleNamespace(sample_rate=16000)
80
+ vio.engine = MagicMock()
81
+ vio.engine.transcribe.return_value = SimpleNamespace(text="the words that were cut off", raw="r")
82
+ vio.cfg = SimpleNamespace(history=None)
83
+ vio._recover_orphans()
84
+ assert saved[0][0] == "the words that were cut off"
85
+ assert saved[0][1]["extra"] == {"recovered": True}
86
+ assert "paste-last" in told[0]
87
+ assert not orphan.exists()
@@ -0,0 +1 @@
1
+ __version__ = "1.14.0"
@@ -0,0 +1,106 @@
1
+ """`voiceio say`: let an agent or a script speak to you, and say who it is.
2
+
3
+ A chime, a desktop notification titled with the sender, then the sender's
4
+ name and the text read aloud. The daemon queues announcements and holds them
5
+ while you dictate: speech played into an open mic would be typed back out,
6
+ and talking over you is rude anyway. Without a daemon, `voiceio say` speaks
7
+ in its own process instead (slower: it loads the voice first).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import queue
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ MAX_TEXT = 600 # characters read aloud; an announcement, not an essay
22
+ _QUEUE = 8 # waiting announcements beyond this are dropped
23
+ _HOLD_SECS = 600 # wait at most this long for a dictation to finish
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Message:
28
+ text: str
29
+ sender: str = ""
30
+ urgent: bool = False
31
+
32
+ @property
33
+ def spoken(self) -> str:
34
+ return f"{self.sender}: {self.text}" if self.sender else self.text
35
+
36
+
37
+ def encode(msg: Message) -> str:
38
+ """The control-socket command for `msg`."""
39
+ return "say:" + json.dumps({"text": msg.text, "from": msg.sender, "urgent": msg.urgent})
40
+
41
+
42
+ def parse(payload: str) -> Message | None:
43
+ try:
44
+ d = json.loads(payload)
45
+ text = " ".join(str(d.get("text", "")).split())[:MAX_TEXT]
46
+ sender = " ".join(str(d.get("from", "")).split())[:60]
47
+ except (ValueError, AttributeError):
48
+ return None
49
+ return Message(text, sender, bool(d.get("urgent"))) if text else None
50
+
51
+
52
+ def cue_and_notify(msg: Message) -> None:
53
+ """The chime and the notification: who is talking, even with no voice."""
54
+ from voiceio import feedback
55
+ if feedback.open_cue_stream():
56
+ (feedback.play_alert_sound if msg.urgent else feedback.play_commit_sound)()
57
+ time.sleep(0.4) # let the chime land before the voice starts
58
+ feedback.notify(msg.sender or "voiceio", msg.text, urgent=msg.urgent)
59
+
60
+
61
+ class Announcer:
62
+ """One announcement at a time, in order, on its own thread."""
63
+
64
+ def __init__(self, speak: Callable[[str], None], busy: Callable[[], bool],
65
+ hold_secs: float = _HOLD_SECS) -> None:
66
+ self._speak = speak
67
+ self._busy = busy
68
+ self._hold_secs = hold_secs
69
+ self._queue: queue.Queue[Message] = queue.Queue(maxsize=_QUEUE)
70
+ threading.Thread(target=self._run, name="announcer", daemon=True).start()
71
+
72
+ def submit(self, msg: Message) -> bool:
73
+ try:
74
+ self._queue.put_nowait(msg)
75
+ return True
76
+ except queue.Full:
77
+ log.warning("say: %d announcements waiting, dropped one from %s",
78
+ _QUEUE, msg.sender or "?")
79
+ return False
80
+
81
+ def _run(self) -> None:
82
+ while True:
83
+ msg = self._queue.get()
84
+ deadline = time.monotonic() + self._hold_secs
85
+ while self._busy() and time.monotonic() < deadline:
86
+ time.sleep(0.25)
87
+ try:
88
+ cue_and_notify(msg)
89
+ self._speak(msg.spoken)
90
+ except Exception:
91
+ log.exception("say: announcement from %s failed", msg.sender or "?")
92
+
93
+
94
+ def say_here(msg: Message) -> bool:
95
+ """No daemon: chime, notify and speak in this process. False if no TTS
96
+ engine works here (the chime and notification still happened)."""
97
+ from voiceio import config, tts
98
+ from voiceio.tts.player import TTSPlayer
99
+ cue_and_notify(msg)
100
+ cfg = config.load().tts
101
+ engine = tts.select(cfg, allow_network=False)
102
+ if engine is None:
103
+ return False
104
+ audio, rate = engine.synthesize(msg.spoken, cfg.voice, cfg.speed)
105
+ TTSPlayer().play(audio, rate)
106
+ return True
@@ -189,6 +189,9 @@ class VoiceIO:
189
189
  # TTS (text-to-speech)
190
190
  self._tts_engine = None
191
191
  self._tts_player = None
192
+ self._announcer = None
193
+ from voiceio.spool import Spool
194
+ self._spool = Spool()
192
195
  self._tts_hotkey = None
193
196
  if cfg.tts.enabled:
194
197
  from voiceio.tts import select as tts_select
@@ -368,7 +371,7 @@ class VoiceIO:
368
371
  if session is not None:
369
372
  session.cancel()
370
373
  self._state = _State.IDLE
371
- self._typers.end(rid)
374
+ self._end_recording(rid)
372
375
  self._idle_audio.touch()
373
376
  log.info("Recording cancelled")
374
377
 
@@ -595,9 +598,10 @@ class VoiceIO:
595
598
  threading.Thread(target=self._capture_context, args=(self._recording,),
596
599
  daemon=True, name="screen-context").start()
597
600
  self._record_start = time.monotonic()
598
- self.recorder.start()
599
- self.recorder.set_on_auto_stop(self._on_auto_stop)
600
601
  rid = self._recording
602
+ self._spool.begin(rid, self.recorder.sample_rate)
603
+ self.recorder.start(tap=lambda chunk, r=rid: self._spool.write(r, chunk))
604
+ self.recorder.set_on_auto_stop(self._on_auto_stop)
601
605
  self.recorder.set_on_dead_stream(lambda: self._revive_mic(rid))
602
606
  # Ducking lowers the whole output, voiceio's chimes included: it waits
603
607
  # for the start chime to play out, and _do_stop restores the volume
@@ -655,13 +659,13 @@ class VoiceIO:
655
659
  self._output_thread(self._process, audio, gen, rid, self._recording_bias)
656
660
  else:
657
661
  # No processing thread will run — release the source here.
658
- self._typers.end(rid)
662
+ self._end_recording(rid)
659
663
  else:
660
664
  # Streaming mode but session already gone (race) — nothing to do
661
665
  self._state = _State.IDLE
662
666
  log.debug("Stop: streaming session already finalized")
663
667
  # No finalizer will run to release the input source, so do it here.
664
- self._typers.end(rid)
668
+ self._end_recording(rid)
665
669
 
666
670
  def _capture_context(self, rid: int) -> None:
667
671
  from voiceio import retention
@@ -766,7 +770,7 @@ class VoiceIO:
766
770
  self._finalizing = None
767
771
  if self._discard_cancelled(gen, extra):
768
772
  log.info("Cancelled dictation discarded")
769
- self._typers.end(rid)
773
+ self._end_recording(rid)
770
774
  return
771
775
  if self._generation != gen:
772
776
  # A later recording has already stopped: this output is stale, but
@@ -776,7 +780,7 @@ class VoiceIO:
776
780
  if final_text:
777
781
  self.engine.commit(final_text, raw=session.raw_final_text,
778
782
  duration=elapsed, extra=extra)
779
- self._typers.end(rid)
783
+ self._end_recording(rid)
780
784
  return
781
785
  if final_text:
782
786
  self._copy_result_async(final_text)
@@ -801,7 +805,7 @@ class VoiceIO:
801
805
  self._warn_if_nothing_captured(final_text, gen)
802
806
  # Release the IBus input source now that the final commit is done.
803
807
  # A no-op if a newer recording has claimed it since.
804
- self._typers.end(rid)
808
+ self._end_recording(rid)
805
809
  # Transition to IDLE under lock to avoid racing with _toggle
806
810
  with self._hotkey_lock:
807
811
  if self._generation == gen and self._state == _State.FINALIZING:
@@ -834,7 +838,7 @@ class VoiceIO:
834
838
  log.exception("Processing failed")
835
839
  finally:
836
840
  # Release the IBus input source after the batch commit (or failure).
837
- self._typers.end(rid)
841
+ self._end_recording(rid)
838
842
 
839
843
  # ── Text-to-speech ─────────────────────────────────────────────
840
844
 
@@ -869,6 +873,62 @@ class VoiceIO:
869
873
  finally:
870
874
  tray.set_processing(False)
871
875
 
876
+ def _end_recording(self, rid: int) -> None:
877
+ """A dictation is over: delivered, saved to history, or cancelled.
878
+ Exactly one call per recording."""
879
+ self._typers.end(rid)
880
+ self._spool.finish(rid)
881
+
882
+ def _recover_orphans(self) -> None:
883
+ """Decode recordings a killed daemon left in the spool into history."""
884
+ from voiceio import feedback, history, spool
885
+ for path, audio, rate in spool.orphans():
886
+ secs = len(audio) / rate
887
+ if rate != self.recorder.sample_rate:
888
+ log.warning("spool: %s is at %d Hz, not %d; left as is", path.name, rate,
889
+ self.recorder.sample_rate)
890
+ continue
891
+ try:
892
+ result = self.engine.transcribe(audio, bias=self.engine.bias())
893
+ except Exception:
894
+ log.exception("spool: could not decode %s (%.0fs); left for the next start",
895
+ path.name, secs)
896
+ continue
897
+ text = (result.text or "").strip()
898
+ if text:
899
+ history.append(text, raw=result.raw, duration=secs,
900
+ extra={"recovered": True}, cfg=self.cfg.history)
901
+ feedback.notify("VoiceIO recovered a dictation",
902
+ f"{secs:.0f}s cut off when voiceio stopped: \"{text[:120]}\" "
903
+ "— `voiceio paste-last` types it", urgent=True)
904
+ log.info("spool: recovered %.0fs into history", secs)
905
+ path.unlink(missing_ok=True)
906
+
907
+ # ── Announcements (`voiceio say`) ───────────────────────────────────
908
+
909
+ def on_say(self, payload: str) -> None:
910
+ """An agent or script asks to be heard: queue it, never talk over it."""
911
+ from voiceio import announce
912
+ msg = announce.parse(payload)
913
+ if msg is None:
914
+ log.warning("say: ignored a malformed request")
915
+ return
916
+ if self._announcer is None:
917
+ self._announcer = announce.Announcer(
918
+ speak=self._speak_now, busy=lambda: self._state in (
919
+ _State.RECORDING, _State.FINALIZING))
920
+ self._announcer.submit(msg)
921
+
922
+ def _speak_now(self, text: str) -> None:
923
+ """Speak to the end (on the announcer's own thread); nothing if no TTS."""
924
+ if self._tts_player is None or self._tts_engine is None:
925
+ return
926
+ while self._tts_player.is_playing(): # the user's own read-aloud goes first
927
+ time.sleep(0.2)
928
+ audio, sample_rate = self._tts_engine.synthesize(
929
+ text, self.cfg.tts.voice, self.cfg.tts.speed)
930
+ self._tts_player.play(audio, sample_rate) # synchronous
931
+
872
932
  # ── Feedback ────────────────────────────────────────────────────────
873
933
 
874
934
  def _play_record_cue(self, start: bool) -> None:
@@ -941,6 +1001,8 @@ class VoiceIO:
941
1001
  control.handle("release", self.on_hotkey_release)
942
1002
  control.handle("cancel", self.cancel)
943
1003
  control.handle("paste-last", self.paste_last)
1004
+ if hasattr(control, "handle_payload"):
1005
+ control.handle_payload("say", self.on_say)
944
1006
  if self._cancel_hotkey is not None:
945
1007
  self._cancel_hotkey.start(self.cfg.hotkey.cancel_key, self.cancel)
946
1008
 
@@ -989,6 +1051,8 @@ class VoiceIO:
989
1051
  f"voiceio v{__version__} ready. Press [{self.cfg.hotkey.key}] to record "
990
1052
  f"(model={self.cfg.model.name}, typer={self._typers.typer.name})",
991
1053
  )
1054
+ threading.Thread(target=self._recover_orphans, daemon=True,
1055
+ name="spool-recover").start()
992
1056
 
993
1057
  # Ctrl-C and `systemctl stop voiceio` must run the SAME cleanup path
994
1058
  # (restore input source, engine shutdown) — the finally block below.
@@ -45,6 +45,17 @@ def main() -> None:
45
45
  sub.add_parser("cancel", help="Abandon the current dictation: nothing is typed or kept"
46
46
  ).set_defaults(func=lambda a: _send("cancel"))
47
47
 
48
+ p_say = sub.add_parser(
49
+ "say", help="Speak to the user: chime, notification, then the text read aloud",
50
+ description="For agents and scripts: `voiceio say --from build-bot 'tests are green'`. "
51
+ "The daemon waits until you stop dictating; without one, it speaks here.")
52
+ p_say.add_argument("text", nargs="+", help="what to say")
53
+ p_say.add_argument("--from", dest="sender", default="",
54
+ help="who is talking, said first and shown as the notification title")
55
+ p_say.add_argument("--urgent", action="store_true",
56
+ help="the alert chime and an urgent notification")
57
+ p_say.set_defaults(func=_cmd_say)
58
+
48
59
  p_setup = sub.add_parser("setup", help="Configure voiceio (interactive wizard)")
49
60
  p_setup.add_argument("--defaults", action="store_true",
50
61
  help="Non-interactive: accept all recommended defaults")
@@ -164,6 +175,20 @@ def _cmd_setup(args: argparse.Namespace) -> None:
164
175
  hint("test", "Run 'voiceio doctor --live' to try a quick recording")
165
176
 
166
177
 
178
+ def _cmd_say(args: argparse.Namespace) -> None:
179
+ from voiceio import announce
180
+ from voiceio.hotkeys.socket_backend import SOCKET_PATH, send_command
181
+ msg = announce.Message(" ".join(" ".join(args.text).split())[:announce.MAX_TEXT],
182
+ " ".join(args.sender.split()), args.urgent)
183
+ if not msg.text:
184
+ raise SystemExit("nothing to say")
185
+ if SOCKET_PATH.exists() and send_command(announce.encode(msg)):
186
+ return
187
+ if not announce.say_here(msg):
188
+ print("no text-to-speech engine works here: sent the notification only "
189
+ "(voiceio doctor shows why)", file=sys.stderr)
190
+
191
+
167
192
  def _send(command: str) -> None:
168
193
  """Send a command to the running daemon."""
169
194
  from voiceio.hotkeys.socket_backend import send_command
@@ -141,6 +141,10 @@ def play_commit_sound() -> None:
141
141
  _play("commit")
142
142
 
143
143
 
144
+ def play_alert_sound() -> None:
145
+ _play("alert")
146
+
147
+
144
148
  # The usual cause, by far: a hardware mic switch, a mute key or a muted
145
149
  # source. Each look the same to voiceio — a stream that sends only zeros.
146
150
  MIC_HINT = "Is the mic switched off or muted (privacy switch, mute key)?"