python-voiceio 1.13.0__tar.gz → 1.15.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.15.0}/PKG-INFO +10 -1
  2. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/README.md +9 -0
  3. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/pyproject.toml +1 -1
  4. {python_voiceio-1.13.0 → python_voiceio-1.15.0/python_voiceio.egg-info}/PKG-INFO +10 -1
  5. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/python_voiceio.egg-info/SOURCES.txt +4 -0
  6. python_voiceio-1.15.0/tests/test_announce.py +84 -0
  7. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_app_wiring.py +1 -1
  8. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_pipeline.py +26 -0
  9. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_presence.py +1 -0
  10. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_report.py +19 -3
  11. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_server.py +35 -7
  12. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_sherpa.py +6 -1
  13. python_voiceio-1.15.0/tests/test_spool.py +87 -0
  14. python_voiceio-1.15.0/voiceio/__init__.py +1 -0
  15. python_voiceio-1.15.0/voiceio/announce.py +106 -0
  16. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/app.py +73 -9
  17. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/__init__.py +25 -0
  18. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/learn.py +2 -0
  19. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/sherpa.py +5 -0
  20. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/feedback.py +4 -0
  21. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/socket_backend.py +11 -1
  22. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/presence.py +4 -1
  23. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/recorder.py +11 -2
  24. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/report.py +13 -0
  25. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/server/__main__.py +1 -0
  26. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/server/app.py +25 -12
  27. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/server/config.py +4 -0
  28. python_voiceio-1.15.0/voiceio/spool.py +116 -0
  29. python_voiceio-1.13.0/voiceio/__init__.py +0 -1
  30. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/LICENSE +0 -0
  31. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  32. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  33. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/python_voiceio.egg-info/requires.txt +0 -0
  34. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/python_voiceio.egg-info/top_level.txt +0 -0
  35. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/setup.cfg +0 -0
  36. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_audio_quality.py +0 -0
  37. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_audio_source.py +0 -0
  38. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_backend_probes.py +0 -0
  39. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_cancel.py +0 -0
  40. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_cli.py +0 -0
  41. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_clipboard_read.py +0 -0
  42. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_clipboard_typer.py +0 -0
  43. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_commands.py +0 -0
  44. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_concurrency_lockdown.py +0 -0
  45. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_config.py +0 -0
  46. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_context_terms.py +0 -0
  47. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_corrections.py +0 -0
  48. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_dead_mic.py +0 -0
  49. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_decoders.py +0 -0
  50. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_engine.py +0 -0
  51. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_evdev_uaccess.py +0 -0
  52. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_fallback.py +0 -0
  53. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_fcitx_bridge.py +0 -0
  54. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_feedback_stream.py +0 -0
  55. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_focus.py +0 -0
  56. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_health.py +0 -0
  57. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_hints.py +0 -0
  58. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_history.py +0 -0
  59. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_hotkey_modes.py +0 -0
  60. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_engine_limits.py +0 -0
  61. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_engine_listener.py +0 -0
  62. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_pending.py +0 -0
  63. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_pids.py +0 -0
  64. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_ping.py +0 -0
  65. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_session.py +0 -0
  66. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_textlimit.py +0 -0
  67. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_ibus_typer.py +0 -0
  68. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_idle_audio.py +0 -0
  69. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_clean.py +0 -0
  70. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_import.py +0 -0
  71. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_language.py +0 -0
  72. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_metrics.py +0 -0
  73. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_quiet.py +0 -0
  74. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_resume.py +0 -0
  75. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_review.py +0 -0
  76. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_learn_targets.py +0 -0
  77. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_llm.py +0 -0
  78. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_llm_api.py +0 -0
  79. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_mic_gain.py +0 -0
  80. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_modifier_guard.py +0 -0
  81. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_numbers.py +0 -0
  82. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_paste_last.py +0 -0
  83. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_pipeline.py +0 -0
  84. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_platform.py +0 -0
  85. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_postcorrect.py +0 -0
  86. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_postprocess.py +0 -0
  87. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_prebuffer.py +0 -0
  88. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_prompt.py +0 -0
  89. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_quiet_audio.py +0 -0
  90. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_recorder_integration.py +0 -0
  91. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_retention.py +0 -0
  92. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_robustness.py +0 -0
  93. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_security_hardening.py +0 -0
  94. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_service.py +0 -0
  95. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_setup.py +0 -0
  96. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_streaming.py +0 -0
  97. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_suggest.py +0 -0
  98. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_tokens.py +0 -0
  99. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_transcriber.py +0 -0
  100. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_tts.py +0 -0
  101. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_typer_manager.py +0 -0
  102. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_vad.py +0 -0
  103. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_vocabulary.py +0 -0
  104. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_wordfreq.py +0 -0
  105. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/tests/test_worker_decode.py +0 -0
  106. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/__main__.py +0 -0
  107. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/audio_source.py +0 -0
  108. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/backends.py +0 -0
  109. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/configcmd.py +0 -0
  110. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/corrections.py +0 -0
  111. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/doctor.py +0 -0
  112. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/history.py +0 -0
  113. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/models.py +0 -0
  114. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/servicecmd.py +0 -0
  115. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/uninstall.py +0 -0
  116. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/cli/vocab.py +0 -0
  117. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/clipboard_read.py +0 -0
  118. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/commands.py +0 -0
  119. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/config.py +0 -0
  120. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/consent.py +0 -0
  121. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/context_terms.py +0 -0
  122. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/__init__.py +0 -0
  123. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/bias.py +0 -0
  124. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/__init__.py +0 -0
  125. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/base.py +0 -0
  126. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/catalog.py +0 -0
  127. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  128. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  129. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/fetch.py +0 -0
  130. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/lanes.py +0 -0
  131. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/decoders/whispercpp.py +0 -0
  132. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/engine.py +0 -0
  133. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/core/pipeline.py +0 -0
  134. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/corrections.py +0 -0
  135. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  136. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/data/__init__.py +0 -0
  137. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/demo.py +0 -0
  138. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/display.py +0 -0
  139. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/focus.py +0 -0
  140. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/health.py +0 -0
  141. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hints.py +0 -0
  142. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/history.py +0 -0
  143. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/__init__.py +0 -0
  144. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/base.py +0 -0
  145. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/chain.py +0 -0
  146. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/evdev.py +0 -0
  147. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  148. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/__init__.py +0 -0
  149. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/engine.py +0 -0
  150. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/install.py +0 -0
  151. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/pending.py +0 -0
  152. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/session.py +0 -0
  153. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ibus/textlimit.py +0 -0
  154. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/idle_audio.py +0 -0
  155. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/__init__.py +0 -0
  156. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/augment.py +0 -0
  157. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/clean.py +0 -0
  158. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/evaluate.py +0 -0
  159. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/importer.py +0 -0
  160. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/metrics.py +0 -0
  161. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/quiet.py +0 -0
  162. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/review.py +0 -0
  163. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/store.py +0 -0
  164. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/teacher.py +0 -0
  165. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/learn/train.py +0 -0
  166. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/llm.py +0 -0
  167. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/llm_api.py +0 -0
  168. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/mic_gain.py +0 -0
  169. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/models/__init__.py +0 -0
  170. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/models/silero_vad.onnx +0 -0
  171. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/numbers.py +0 -0
  172. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/pidlock.py +0 -0
  173. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/platform.py +0 -0
  174. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/postcorrect.py +0 -0
  175. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/postprocess.py +0 -0
  176. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/power.py +0 -0
  177. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/privacy.py +0 -0
  178. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/prompt.py +0 -0
  179. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/quiet_audio.py +0 -0
  180. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/retention.py +0 -0
  181. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/server/__init__.py +0 -0
  182. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/server/runtime.py +0 -0
  183. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/service.py +0 -0
  184. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/__init__.py +0 -0
  185. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/configfile.py +0 -0
  186. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/extras.py +0 -0
  187. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/hotkey.py +0 -0
  188. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/noninteractive.py +0 -0
  189. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/speech.py +0 -0
  190. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/steps.py +0 -0
  191. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/system.py +0 -0
  192. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/setup/ui.py +0 -0
  193. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/sounds/__init__.py +0 -0
  194. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/sounds/alert.wav +0 -0
  195. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/sounds/commit.wav +0 -0
  196. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/sounds/start.wav +0 -0
  197. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/sounds/stop.wav +0 -0
  198. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/streaming.py +0 -0
  199. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/suggest.py +0 -0
  200. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tokens.py +0 -0
  201. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tray/__init__.py +0 -0
  202. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tray/_icons.py +0 -0
  203. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tray/_indicator.py +0 -0
  204. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tray/_pystray.py +0 -0
  205. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/__init__.py +0 -0
  206. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/base.py +0 -0
  207. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/chain.py +0 -0
  208. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/edge_engine.py +0 -0
  209. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/espeak.py +0 -0
  210. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/piper_engine.py +0 -0
  211. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/player.py +0 -0
  212. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/tts/supertonic_engine.py +0 -0
  213. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/__init__.py +0 -0
  214. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/base.py +0 -0
  215. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/chain.py +0 -0
  216. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/clipboard.py +0 -0
  217. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/collecting.py +0 -0
  218. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/fcitx_bridge.py +0 -0
  219. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/ibus.py +0 -0
  220. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/manager.py +0 -0
  221. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/modifier_guard.py +0 -0
  222. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/paste.py +0 -0
  223. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/pynput_type.py +0 -0
  224. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/wtype.py +0 -0
  225. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/xdotool.py +0 -0
  226. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/typers/ydotool.py +0 -0
  227. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/ui.py +0 -0
  228. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/vad.py +0 -0
  229. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/vocab_stats.py +0 -0
  230. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/vocabulary.py +0 -0
  231. {python_voiceio-1.13.0 → python_voiceio-1.15.0}/voiceio/watchdog.py +0 -0
  232. {python_voiceio-1.13.0 → python_voiceio-1.15.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.15.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.15.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.15.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
 
@@ -307,6 +307,32 @@ def test_status_shows_scores_and_suggests_a_winning_run(capsys):
307
307
  assert "next: voiceio learn promote v0001-small-x" in out
308
308
 
309
309
 
310
+ def test_status_never_suggests_whisper_over_another_decoder(capsys):
311
+ import argparse
312
+
313
+ from voiceio import config
314
+ from voiceio.cli import learn as cli
315
+ config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
316
+ config.CONFIG_PATH.write_text('[model]\nname = "parakeet-tdt-0.6b-v2"\n')
317
+ _clip("s.wav", 3, "x")
318
+ store.add_label("s.wav", [Segment(0, 3, "hi")], "teacher:fake")
319
+ ds = store.build_dataset()
320
+ run = store.learn_dir() / "models" / "v0001-small-x"
321
+ (run / "ct2").mkdir(parents=True)
322
+ runs = store.learn_dir() / "runs"
323
+ runs.mkdir()
324
+ for i, (dec, wer, rec) in enumerate([("sherpa:parakeet-tdt-0.6b-v2", 0.10, 0.5),
325
+ (f"faster-whisper:{run / 'ct2'}", 0.08, 0.8)]):
326
+ (runs / f"eval-2026010{i}.json").write_text(json.dumps({
327
+ "decoder": dec, "dataset": ds.name, "mode": "offline", "wer": wer,
328
+ "term_recall": rec, "repetitions": 0, "clips": 5, "gold": 0,
329
+ "metrics": metrics.METRICS_VERSION}))
330
+ cli.cmd_status(argparse.Namespace())
331
+ out = capsys.readouterr().out
332
+ assert "parakeet-tdt-0.6b-v2 (active)" in out and "WER 10.0%" in out
333
+ assert "next: voiceio learn promote" not in out
334
+
335
+
310
336
  def test_paired_comparison_reports_direction_and_uncertainty():
311
337
  clips = [f"{i}.wav" for i in range(20)]
312
338
  cur = {"per_clip": [{"file": f, "wer": 0.10} for f in clips]}
@@ -38,6 +38,7 @@ AWAY = 600
38
38
  (Probe(idle=1800), False, "away 30 min"),
39
39
  (Probe(idle=1800, inhibited=True), True, "keeping the session awake"), # a video
40
40
  (Probe(idle=1800, inhibited=True, locked=True), False, "away"),
41
+ (Probe(idle=3 * 3600, inhibited=True), False, "away 180 min"), # an agent, not a viewer
41
42
  # No idle monitor: logind decides, and "can't tell" means in use.
42
43
  (Probe(), True, "can't tell"),
43
44
  (Probe(session=SeatSession(exists=False)), False, "nobody is logged in"),
@@ -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
@@ -310,6 +310,7 @@ def test_ws_ready_partial_final_sequence():
310
310
  assert final["text"] == "Hello world."
311
311
  assert final["raw"] == "hello world raw"
312
312
  assert isinstance(final["ms"], int)
313
+ assert final["cut_short"] is False and "why" not in final
313
314
  assert engine.committed == ["Hello world."] # learned from; kept if opted in
314
315
 
315
316
 
@@ -348,8 +349,9 @@ def test_ws_client_disconnect_still_finalizes():
348
349
  assert engine.sessions[0].stopped_with is not None
349
350
 
350
351
 
351
- def test_ws_enforces_the_duration_cap():
352
+ def test_ws_duration_cap_finalizes_what_fits_marked_cut_short():
352
353
  cfg = ServerConfig(max_audio_secs=0.05) # 800 samples
354
+ engine = FakeEngine()
353
355
 
354
356
  async def go(client):
355
357
  ws = await client.ws_connect("/ws/transcribe")
@@ -361,11 +363,36 @@ def test_ws_enforces_the_duration_cap():
361
363
  await ws.close()
362
364
  return frames
363
365
 
364
- frames = run_with_client(go, cfg=cfg)
365
- kinds = [f["type"] for f in frames]
366
- assert "error" in kinds
367
- # An overrun must not also claim a usable final transcript.
368
- assert "final" not in kinds
366
+ frames = run_with_client(go, cfg=cfg, engine=engine)
367
+ final = frames[-1]
368
+ assert final["type"] == "final" and final["text"] == "Hello world."
369
+ # The words are kept, and the client is told they are not the whole note.
370
+ assert final["cut_short"] is True and "limit" in final["why"]
371
+ assert len(engine.sessions[0].stopped_with) == 800 # only what fits is decoded
372
+
373
+
374
+ def test_ws_stalled_stream_is_finalized_with_what_it_has():
375
+ """A client that goes quiet without a stop (a starved or frozen sender)
376
+ still gets a transcript of the audio it sent."""
377
+ cfg = ServerConfig(stall_secs=0.2)
378
+ engine = FakeEngine()
379
+
380
+ async def go(client):
381
+ ws = await client.ws_connect("/ws/transcribe")
382
+ await ws.receive_json()
383
+ await ws.send_bytes(_pcm(800))
384
+ frames = []
385
+ async for msg in ws: # no stop: the server ends it
386
+ frames.append(json.loads(msg.data))
387
+ await ws.close()
388
+ return frames
389
+
390
+ frames = run_with_client(go, cfg=cfg, engine=engine)
391
+ final = frames[-1]
392
+ assert final["type"] == "final" and final["text"] == "Hello world."
393
+ assert final["cut_short"] is True and "no audio or stop" in final["why"]
394
+ assert len(engine.sessions[0].stopped_with) == 800
395
+ assert engine.committed == ["Hello world."]
369
396
 
370
397
 
371
398
  def test_ws_second_concurrent_session_is_refused():
@@ -439,11 +466,12 @@ def test_config_from_env(monkeypatch):
439
466
  monkeypatch.setenv("VOICE_MODEL", "base")
440
467
  monkeypatch.setenv("VOICE_THREADS", "2")
441
468
  monkeypatch.setenv("VOICE_MAX_SECS", "60")
469
+ monkeypatch.setenv("VOICE_STALL_SECS", "45")
442
470
  monkeypatch.setenv("VOICE_TTS_ALLOW_NETWORK", "1")
443
471
  cfg = ServerConfig.from_env()
444
472
  assert (cfg.host, cfg.port, cfg.model) == ("0.0.0.0", 8790, "base")
445
473
  assert cfg.threads == "2"
446
- assert cfg.max_audio_secs == 60.0
474
+ assert (cfg.max_audio_secs, cfg.stall_secs) == (60.0, 45.0)
447
475
  assert cfg.tts_allow_network is True
448
476
 
449
477
 
@@ -41,6 +41,10 @@ class FakeSherpa:
41
41
  self.rec = {"hotwords": hotwords, "samples": 0}
42
42
  fake.streams.append(self.rec)
43
43
 
44
+ def set_option(self, key, value):
45
+ assert isinstance(value, str)
46
+ self.rec[key] = value
47
+
44
48
  def accept_waveform(self, sr, a):
45
49
  assert sr == 16000 and a.dtype == np.float32
46
50
  self.rec["samples"] = len(a)
@@ -114,6 +118,7 @@ def test_one_recognizer_and_per_stream_pretokenized_proper_nouns(fake, tmp_path)
114
118
  assert out.text == "hello world"
115
119
  # Lowercase (mined jargon) and unusable terms never become hotwords.
116
120
  assert fake.streams[-1]["hotwords"] == "▁Kal shi/▁Hy per"
121
+ assert fake.streams[-1]["length_reward"] == "0.5" # per stream, as upstream reads it
117
122
  dec.decode(_audio(2), bias=Bias(hotwords="Hyper"))
118
123
  dec.decode(_audio(2), bias=Bias())
119
124
  assert [s["hotwords"] for s in fake.streams[1:]] == ["▁Hy per", None]
@@ -135,7 +140,7 @@ def test_without_the_tdt_fix_decodes_greedy_and_unbiased(fake, monkeypatch, tmp_
135
140
  assert dec.decode(_audio(1), bias=Bias(hotwords="Kalshi")).text == "hello world"
136
141
  kw = fake.created[-1]
137
142
  assert kw["decoding_method"] == "greedy_search" and "length_reward" not in kw
138
- assert fake.streams[-1]["hotwords"] is None
143
+ assert fake.streams[-1]["hotwords"] is None and "length_reward" not in fake.streams[-1]
139
144
 
140
145
 
141
146
  def test_long_audio_is_decoded_in_pieces_of_at_most_30s(fake, tmp_path):
@@ -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.15.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