python-voiceio 1.9.2__tar.gz → 1.9.3__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 (222) hide show
  1. {python_voiceio-1.9.2/python_voiceio.egg-info → python_voiceio-1.9.3}/PKG-INFO +2 -2
  2. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/README.md +1 -1
  3. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/pyproject.toml +1 -1
  4. {python_voiceio-1.9.2 → python_voiceio-1.9.3/python_voiceio.egg-info}/PKG-INFO +2 -2
  5. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/python_voiceio.egg-info/SOURCES.txt +4 -0
  6. python_voiceio-1.9.3/tests/test_dead_mic.py +94 -0
  7. python_voiceio-1.9.3/tests/test_learn_quiet.py +247 -0
  8. python_voiceio-1.9.3/tests/test_learn_resume.py +243 -0
  9. python_voiceio-1.9.3/tests/test_presence.py +99 -0
  10. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_service.py +33 -1
  11. python_voiceio-1.9.3/voiceio/__init__.py +1 -0
  12. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/app.py +33 -0
  13. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/learn.py +127 -35
  14. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/config.py +9 -3
  15. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/evaluate.py +4 -1
  16. python_voiceio-1.9.3/voiceio/learn/quiet.py +172 -0
  17. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/store.py +9 -3
  18. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/teacher.py +3 -3
  19. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/train.py +219 -44
  20. python_voiceio-1.9.3/voiceio/presence.py +160 -0
  21. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/recorder.py +38 -0
  22. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/service.py +28 -6
  23. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/steps.py +1 -1
  24. python_voiceio-1.9.2/tests/test_learn_quiet.py +0 -103
  25. python_voiceio-1.9.2/voiceio/__init__.py +0 -1
  26. python_voiceio-1.9.2/voiceio/learn/quiet.py +0 -41
  27. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/LICENSE +0 -0
  28. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
  29. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/python_voiceio.egg-info/entry_points.txt +0 -0
  30. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/python_voiceio.egg-info/requires.txt +0 -0
  31. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/python_voiceio.egg-info/top_level.txt +0 -0
  32. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/setup.cfg +0 -0
  33. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_app_wiring.py +0 -0
  34. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_audio_quality.py +0 -0
  35. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_audio_source.py +0 -0
  36. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_backend_probes.py +0 -0
  37. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_cancel.py +0 -0
  38. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_cli.py +0 -0
  39. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_clipboard_read.py +0 -0
  40. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_clipboard_typer.py +0 -0
  41. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_commands.py +0 -0
  42. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_concurrency_lockdown.py +0 -0
  43. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_config.py +0 -0
  44. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_context_terms.py +0 -0
  45. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_corrections.py +0 -0
  46. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_decoders.py +0 -0
  47. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_engine.py +0 -0
  48. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_evdev_uaccess.py +0 -0
  49. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_fallback.py +0 -0
  50. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_fcitx_bridge.py +0 -0
  51. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_feedback_stream.py +0 -0
  52. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_focus.py +0 -0
  53. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_health.py +0 -0
  54. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_hints.py +0 -0
  55. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_history.py +0 -0
  56. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_hotkey_modes.py +0 -0
  57. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_engine_limits.py +0 -0
  58. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_engine_listener.py +0 -0
  59. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_pending.py +0 -0
  60. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_pids.py +0 -0
  61. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_ping.py +0 -0
  62. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_session.py +0 -0
  63. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_textlimit.py +0 -0
  64. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_ibus_typer.py +0 -0
  65. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_idle_audio.py +0 -0
  66. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_clean.py +0 -0
  67. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_import.py +0 -0
  68. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_language.py +0 -0
  69. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_metrics.py +0 -0
  70. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_pipeline.py +0 -0
  71. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_review.py +0 -0
  72. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_learn_targets.py +0 -0
  73. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_llm.py +0 -0
  74. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_llm_api.py +0 -0
  75. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_modifier_guard.py +0 -0
  76. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_numbers.py +0 -0
  77. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_paste_last.py +0 -0
  78. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_pipeline.py +0 -0
  79. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_platform.py +0 -0
  80. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_postcorrect.py +0 -0
  81. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_postprocess.py +0 -0
  82. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_prebuffer.py +0 -0
  83. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_prompt.py +0 -0
  84. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_quiet_audio.py +0 -0
  85. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_recorder_integration.py +0 -0
  86. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_retention.py +0 -0
  87. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_robustness.py +0 -0
  88. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_security_hardening.py +0 -0
  89. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_server.py +0 -0
  90. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_setup.py +0 -0
  91. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_streaming.py +0 -0
  92. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_suggest.py +0 -0
  93. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_tokens.py +0 -0
  94. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_transcriber.py +0 -0
  95. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_tts.py +0 -0
  96. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_typer_manager.py +0 -0
  97. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_vad.py +0 -0
  98. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_vocabulary.py +0 -0
  99. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_wordfreq.py +0 -0
  100. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/tests/test_worker_decode.py +0 -0
  101. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/__main__.py +0 -0
  102. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/audio_source.py +0 -0
  103. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/backends.py +0 -0
  104. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/__init__.py +0 -0
  105. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/configcmd.py +0 -0
  106. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/corrections.py +0 -0
  107. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/doctor.py +0 -0
  108. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/history.py +0 -0
  109. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/models.py +0 -0
  110. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/servicecmd.py +0 -0
  111. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/uninstall.py +0 -0
  112. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/cli/vocab.py +0 -0
  113. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/clipboard_read.py +0 -0
  114. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/commands.py +0 -0
  115. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/consent.py +0 -0
  116. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/context_terms.py +0 -0
  117. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/__init__.py +0 -0
  118. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/bias.py +0 -0
  119. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/__init__.py +0 -0
  120. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/base.py +0 -0
  121. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/catalog.py +0 -0
  122. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  123. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  124. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/lanes.py +0 -0
  125. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/sherpa.py +0 -0
  126. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/decoders/whispercpp.py +0 -0
  127. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/engine.py +0 -0
  128. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/core/pipeline.py +0 -0
  129. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/corrections.py +0 -0
  130. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  131. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/data/__init__.py +0 -0
  132. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/demo.py +0 -0
  133. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/display.py +0 -0
  134. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/feedback.py +0 -0
  135. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/focus.py +0 -0
  136. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/health.py +0 -0
  137. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hints.py +0 -0
  138. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/history.py +0 -0
  139. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/__init__.py +0 -0
  140. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/base.py +0 -0
  141. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/chain.py +0 -0
  142. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/evdev.py +0 -0
  143. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/pynput_backend.py +0 -0
  144. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/hotkeys/socket_backend.py +0 -0
  145. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/__init__.py +0 -0
  146. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/engine.py +0 -0
  147. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/install.py +0 -0
  148. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/pending.py +0 -0
  149. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/session.py +0 -0
  150. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ibus/textlimit.py +0 -0
  151. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/idle_audio.py +0 -0
  152. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/__init__.py +0 -0
  153. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/augment.py +0 -0
  154. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/clean.py +0 -0
  155. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/importer.py +0 -0
  156. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/metrics.py +0 -0
  157. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/learn/review.py +0 -0
  158. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/llm.py +0 -0
  159. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/llm_api.py +0 -0
  160. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/models/__init__.py +0 -0
  161. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/models/silero_vad.onnx +0 -0
  162. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/numbers.py +0 -0
  163. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/pidlock.py +0 -0
  164. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/platform.py +0 -0
  165. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/postcorrect.py +0 -0
  166. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/postprocess.py +0 -0
  167. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/power.py +0 -0
  168. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/privacy.py +0 -0
  169. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/prompt.py +0 -0
  170. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/quiet_audio.py +0 -0
  171. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/retention.py +0 -0
  172. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/server/__init__.py +0 -0
  173. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/server/__main__.py +0 -0
  174. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/server/app.py +0 -0
  175. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/server/config.py +0 -0
  176. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/server/runtime.py +0 -0
  177. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/__init__.py +0 -0
  178. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/configfile.py +0 -0
  179. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/extras.py +0 -0
  180. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/hotkey.py +0 -0
  181. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/noninteractive.py +0 -0
  182. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/speech.py +0 -0
  183. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/system.py +0 -0
  184. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/setup/ui.py +0 -0
  185. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/sounds/__init__.py +0 -0
  186. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/sounds/commit.wav +0 -0
  187. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/sounds/start.wav +0 -0
  188. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/sounds/stop.wav +0 -0
  189. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/streaming.py +0 -0
  190. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/suggest.py +0 -0
  191. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tokens.py +0 -0
  192. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tray/__init__.py +0 -0
  193. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tray/_icons.py +0 -0
  194. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tray/_indicator.py +0 -0
  195. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tray/_pystray.py +0 -0
  196. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/__init__.py +0 -0
  197. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/base.py +0 -0
  198. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/chain.py +0 -0
  199. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/edge_engine.py +0 -0
  200. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/espeak.py +0 -0
  201. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/piper_engine.py +0 -0
  202. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/tts/player.py +0 -0
  203. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/__init__.py +0 -0
  204. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/base.py +0 -0
  205. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/chain.py +0 -0
  206. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/clipboard.py +0 -0
  207. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/collecting.py +0 -0
  208. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/fcitx_bridge.py +0 -0
  209. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/ibus.py +0 -0
  210. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/manager.py +0 -0
  211. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/modifier_guard.py +0 -0
  212. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/paste.py +0 -0
  213. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/pynput_type.py +0 -0
  214. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/wtype.py +0 -0
  215. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/xdotool.py +0 -0
  216. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/typers/ydotool.py +0 -0
  217. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/ui.py +0 -0
  218. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/vad.py +0 -0
  219. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/vocab_stats.py +0 -0
  220. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/vocabulary.py +0 -0
  221. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/watchdog.py +0 -0
  222. {python_voiceio-1.9.2 → python_voiceio-1.9.3}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -98,7 +98,7 @@ voiceio learn schedule on # learn from your recordings while the mac
98
98
  voiceio learn status # what it learned and how each model scores
99
99
  ```
100
100
 
101
- Scheduled learning runs daily or weekly at a time you pick, only on an idle machine on mains power, with a notification you can stop. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
101
+ 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).
102
102
 
103
103
  ## Configure
104
104
 
@@ -45,7 +45,7 @@ voiceio learn schedule on # learn from your recordings while the mac
45
45
  voiceio learn status # what it learned and how each model scores
46
46
  ```
47
47
 
48
- Scheduled learning runs daily or weekly at a time you pick, only on an idle machine on mains power, with a notification you can stop. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
48
+ 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).
49
49
 
50
50
  ## Configure
51
51
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.9.2"
7
+ version = "1.9.3"
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.9.2
3
+ Version: 1.9.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -98,7 +98,7 @@ voiceio learn schedule on # learn from your recordings while the mac
98
98
  voiceio learn status # what it learned and how each model scores
99
99
  ```
100
100
 
101
- Scheduled learning runs daily or weekly at a time you pick, only on an idle machine on mains power, with a notification you can stop. It never switches models unless the new one wins, and `voiceio learn rollback` undoes a switch. Details: [docs/learning.md](docs/learning.md).
101
+ 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).
102
102
 
103
103
  ## Configure
104
104
 
@@ -20,6 +20,7 @@ tests/test_concurrency_lockdown.py
20
20
  tests/test_config.py
21
21
  tests/test_context_terms.py
22
22
  tests/test_corrections.py
23
+ tests/test_dead_mic.py
23
24
  tests/test_decoders.py
24
25
  tests/test_engine.py
25
26
  tests/test_evdev_uaccess.py
@@ -46,6 +47,7 @@ tests/test_learn_language.py
46
47
  tests/test_learn_metrics.py
47
48
  tests/test_learn_pipeline.py
48
49
  tests/test_learn_quiet.py
50
+ tests/test_learn_resume.py
49
51
  tests/test_learn_review.py
50
52
  tests/test_learn_targets.py
51
53
  tests/test_llm.py
@@ -58,6 +60,7 @@ tests/test_platform.py
58
60
  tests/test_postcorrect.py
59
61
  tests/test_postprocess.py
60
62
  tests/test_prebuffer.py
63
+ tests/test_presence.py
61
64
  tests/test_prompt.py
62
65
  tests/test_quiet_audio.py
63
66
  tests/test_recorder_integration.py
@@ -104,6 +107,7 @@ voiceio/platform.py
104
107
  voiceio/postcorrect.py
105
108
  voiceio/postprocess.py
106
109
  voiceio/power.py
110
+ voiceio/presence.py
107
111
  voiceio/privacy.py
108
112
  voiceio/prompt.py
109
113
  voiceio/quiet_audio.py
@@ -0,0 +1,94 @@
1
+ """A mic stream that dies mid-recording is reopened, not recorded to the end.
2
+
3
+ The real failure: after a suspend/resume the stream delivered 2 s of audio,
4
+ then 102 s of exact zeros; the whole dictation was lost.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ from unittest.mock import MagicMock, patch
10
+
11
+ import numpy as np
12
+
13
+ from voiceio.config import AudioConfig
14
+ from voiceio.recorder import AudioRecorder
15
+
16
+
17
+ def _recorder() -> AudioRecorder:
18
+ rec = AudioRecorder(AudioConfig(sample_rate=16000, prebuffer_secs=0.5,
19
+ auto_stop_silence_secs=0, auto_stop_no_speech_secs=0))
20
+ rec._stream = MagicMock()
21
+ return rec
22
+
23
+
24
+ def _feed(rec: AudioRecorder, value: float, secs: float) -> None:
25
+ for _ in range(int(secs * 16000 / 1600)):
26
+ rec._callback(np.full((1600, 1), value, dtype=np.float32), 1600, None, None)
27
+
28
+
29
+ def _armed(rec: AudioRecorder) -> threading.Event:
30
+ fired = threading.Event()
31
+ rec.set_on_dead_stream(fired.set)
32
+ return fired
33
+
34
+
35
+ def test_zeros_after_real_audio_fire_once():
36
+ rec = _recorder()
37
+ rec.start()
38
+ fired = _armed(rec)
39
+ _feed(rec, 0.02, 1.0)
40
+ _feed(rec, 0.0, 1.2)
41
+ assert fired.wait(2)
42
+ fired.clear()
43
+ _feed(rec, 0.0, 1.2) # still reviving: not fired again
44
+ assert not fired.wait(0.2)
45
+
46
+
47
+ def test_a_mic_silent_from_the_start_is_left_to_the_muted_check():
48
+ rec = _recorder()
49
+ rec.start()
50
+ fired = _armed(rec)
51
+ _feed(rec, 0.0, 2.0)
52
+ assert not fired.wait(0.2)
53
+
54
+
55
+ def test_quiet_room_noise_is_not_a_dead_stream():
56
+ rec = _recorder()
57
+ rec.start()
58
+ fired = _armed(rec)
59
+ _feed(rec, 0.02, 0.5)
60
+ _feed(rec, 0.0005, 3.0) # a working mic's silence is never exact zero
61
+ assert not fired.wait(0.2)
62
+
63
+
64
+ def test_revive_reopens_and_allows_at_most_two_attempts():
65
+ rec = _recorder()
66
+ rec.start()
67
+ with patch.object(rec, "open_stream") as opened, patch.object(rec, "close_stream"):
68
+ for expected in (True, True, False):
69
+ fired = _armed(rec)
70
+ _feed(rec, 0.02, 0.2)
71
+ _feed(rec, 0.0, 1.2)
72
+ assert fired.wait(0.5) is expected
73
+ if expected:
74
+ rec.revive_stream()
75
+ assert opened.call_count == 2
76
+ assert rec.is_recording
77
+
78
+
79
+ def test_daemon_revives_and_warns_only_when_still_dead():
80
+ from voiceio.app import VoiceIO, _State
81
+ vio = VoiceIO.__new__(VoiceIO)
82
+ vio._hotkey_lock = threading.Lock()
83
+ vio._state = _State.RECORDING
84
+ vio._recording = 7
85
+ vio.recorder = MagicMock(silent_signal_secs=0.0)
86
+ with patch("voiceio.app.time.sleep"), patch("voiceio.feedback.notify") as notify:
87
+ vio._revive_mic(7)
88
+ vio.recorder.revive_stream.assert_called_once()
89
+ notify.assert_not_called()
90
+ vio.recorder.silent_signal_secs = 1.5
91
+ vio._revive_mic(7)
92
+ notify.assert_called_once()
93
+ vio._revive_mic(8) # a later recording: not ours to touch
94
+ assert vio.recorder.revive_stream.call_count == 2
@@ -0,0 +1,247 @@
1
+ import argparse
2
+
3
+ import pytest
4
+
5
+ from voiceio import power
6
+ from voiceio.learn import quiet
7
+
8
+
9
+ def _supply(root, name, kind, online):
10
+ d = root / name
11
+ d.mkdir()
12
+ (d / "type").write_text(kind + "\n")
13
+ (d / "online").write_text(online + "\n")
14
+
15
+
16
+ def test_on_battery_needs_a_mains_supply_that_is_offline(tmp_path):
17
+ assert not power.on_battery(tmp_path) # desktop: no supplies listed
18
+ _supply(tmp_path, "BAT0", "Battery", "0")
19
+ _supply(tmp_path, "AC", "Mains", "0")
20
+ assert power.on_battery(tmp_path)
21
+ (tmp_path / "AC" / "online").write_text("1\n")
22
+ assert not power.on_battery(tmp_path)
23
+
24
+
25
+ def test_busy_reason_is_load_per_cpu_then_battery(monkeypatch):
26
+ monkeypatch.setattr(quiet.os, "cpu_count", lambda: 8)
27
+ monkeypatch.setattr(quiet, "on_battery", lambda: False)
28
+ monkeypatch.setattr(quiet.os, "getloadavg", lambda: (4.0, 0, 0)) # 0.5 per CPU
29
+ assert "load 0.50 per CPU" in quiet.busy_reason(0.25)
30
+ monkeypatch.setattr(quiet.os, "getloadavg", lambda: (1.0, 0, 0))
31
+ assert quiet.busy_reason(0.25) is None
32
+ monkeypatch.setattr(quiet, "on_battery", lambda: True)
33
+ assert quiet.busy_reason(0.25) == "on battery"
34
+
35
+
36
+ def test_wait_until_quiet_waits_then_gives_up(monkeypatch):
37
+ now = [0.0]
38
+ sleeps = []
39
+ reasons = iter(["busy", "busy", None])
40
+ monkeypatch.setattr(quiet, "busy_reason", lambda *_: next(reasons))
41
+ args = dict(poll=300, sleep=lambda s: (sleeps.append(s), now.__setitem__(0, now[0] + s)),
42
+ clock=lambda: now[0], say=lambda _: None)
43
+ assert quiet.wait_until_quiet(0.25, 3, **args)
44
+ assert sleeps == [300, 300]
45
+
46
+ monkeypatch.setattr(quiet, "busy_reason", lambda *_: "on battery")
47
+ said = []
48
+ now[0] = 0.0
49
+ assert not quiet.wait_until_quiet(0.25, 1, **{**args, "say": said.append})
50
+ assert now[0] <= 3600 and said[-1].startswith("still on battery after 1 h")
51
+
52
+
53
+ def test_busy_reason_waits_for_the_user_to_be_away(monkeypatch):
54
+ from voiceio import presence
55
+ monkeypatch.setattr(quiet, "on_battery", lambda: False)
56
+ monkeypatch.setattr(quiet.os, "getloadavg", lambda: (0.0, 0, 0))
57
+ seen = []
58
+ monkeypatch.setattr(presence, "check",
59
+ lambda away: seen.append(away) or presence.Presence(True, "in use (x)"))
60
+ assert quiet.busy_reason(0.25, away_after=600) == "in use (x)" and seen == [600]
61
+ assert quiet.busy_reason(0.25) is None # unattended `maintain`: no presence check
62
+ monkeypatch.setattr(presence, "check", lambda away: presence.Presence(False, "away"))
63
+ assert quiet.busy_reason(0.25, away_after=600) is None
64
+
65
+
66
+ def test_waiting_logs_each_reason_once_not_every_reading(monkeypatch):
67
+ readings = iter(["in use (last input 3 s ago)", "in use (last input 9 s ago)",
68
+ "on battery", None])
69
+ monkeypatch.setattr(quiet, "busy_reason", lambda *_: next(readings))
70
+ said = []
71
+ assert quiet.wait_until_quiet(0.25, 3, poll=1, sleep=lambda s: None, say=said.append)
72
+ assert said == ["waiting: in use (last input 3 s ago)", "waiting: on battery"]
73
+
74
+
75
+ class _Proc:
76
+ """A worker that runs for `polls` checks, then exits with `code`."""
77
+ def __init__(self, polls=99, code=0):
78
+ self.left, self.code, self.pid, self.stopped = polls, code, 4242, False
79
+
80
+ def poll(self):
81
+ if self.stopped:
82
+ return -15
83
+ if self.left <= 0:
84
+ return self.code
85
+ self.left -= 1
86
+ return None
87
+
88
+ @property
89
+ def returncode(self):
90
+ return self.poll()
91
+
92
+
93
+ def _watch(monkeypatch, tmp_path, procs, reasons, *, quiet_ok=True):
94
+ spawned, said = [], []
95
+ procs = iter(procs)
96
+ reasons = iter(reasons)
97
+
98
+ def spawn(argv, env):
99
+ spawned.append(env)
100
+ return next(procs)
101
+
102
+ def stop(proc):
103
+ proc.stopped = True
104
+ monkeypatch.setattr(quiet, "wait_until_quiet", lambda *a, **k: quiet_ok)
105
+ monkeypatch.setattr(quiet, "stop_reason", lambda away: next(reasons, None))
106
+ code = quiet.run_while_away(["voiceio", "learn", "maintain"], max_load=0.25, away_minutes=10,
107
+ wait_hours=3, threads=3, hold_file=tmp_path / "hold",
108
+ spawn=spawn, stop=stop, sleep=lambda s: None, say=said.append)
109
+ return code, spawned, said
110
+
111
+
112
+ def test_work_stops_when_the_user_returns_and_resumes_later(monkeypatch, tmp_path):
113
+ first, second = _Proc(), _Proc(polls=3, code=0)
114
+ code, spawned, said = _watch(monkeypatch, tmp_path, [first, second],
115
+ [None, None, "in use (last input 0 s ago)"])
116
+ assert code == 0 and first.stopped and not second.stopped
117
+ assert said == ["paused: in use (last input 0 s ago); it resumes where it stopped "
118
+ "once you've been away 10 min"]
119
+ assert spawned[0][quiet.WORKER_ENV] == "1" and spawned[0]["OMP_NUM_THREADS"] == "3"
120
+ assert quiet.RESUMED_ENV not in spawned[0] and spawned[1][quiet.RESUMED_ENV] == "1"
121
+
122
+
123
+ def test_watcher_reports_the_work_exit_status_and_gives_up_quietly(monkeypatch, tmp_path):
124
+ assert _watch(monkeypatch, tmp_path, [_Proc(polls=1, code=3)], [])[0] == 3
125
+ code, spawned, _ = _watch(monkeypatch, tmp_path, [], [], quiet_ok=False)
126
+ assert code == 0 and spawned == [] # never away: nothing started, unit doesn't fail
127
+
128
+
129
+ def test_a_model_switch_in_progress_is_never_cut_off(monkeypatch, tmp_path):
130
+ proc = _Proc(polls=3, code=0)
131
+ reasons = iter(["in use"] * 10)
132
+
133
+ def spawn(argv, env):
134
+ (tmp_path / "hold").touch() # the worker holds it
135
+ return proc
136
+ monkeypatch.setattr(quiet, "wait_until_quiet", lambda *a, **k: True)
137
+ monkeypatch.setattr(quiet, "stop_reason", lambda away: next(reasons))
138
+ assert quiet.run_while_away(["x"], max_load=1, away_minutes=1, wait_hours=1, threads=1,
139
+ hold_file=tmp_path / "hold", spawn=spawn,
140
+ stop=lambda p: pytest.fail("cut off a model switch"),
141
+ sleep=lambda s: None, say=print) == 0
142
+ assert not (tmp_path / "hold").exists()
143
+
144
+
145
+ def test_hold_marks_the_file_only_inside_the_block(monkeypatch, tmp_path):
146
+ flag = tmp_path / "hold"
147
+ monkeypatch.setenv(quiet.HOLD_ENV, str(flag))
148
+ with quiet.hold():
149
+ assert flag.exists()
150
+ assert not flag.exists()
151
+ monkeypatch.delenv(quiet.HOLD_ENV)
152
+ with quiet.hold(): # not under the watcher: a no-op
153
+ pass
154
+
155
+
156
+ def test_stop_ends_the_whole_process_group_at_once():
157
+ """The decoder runs in a subprocess of the worker: it must go too."""
158
+ import os
159
+ import time
160
+ proc = quiet._spawn(["sh", "-c", "sleep 30 & sleep 30; wait"], dict(os.environ))
161
+ time.sleep(0.2)
162
+ t0 = time.monotonic()
163
+ quiet._stop(proc)
164
+ assert time.monotonic() - t0 < 3 and proc.poll() is not None
165
+ with pytest.raises(ProcessLookupError):
166
+ os.killpg(proc.pid, 0) # nothing left in the group
167
+
168
+
169
+ def test_threads_default_to_half_the_cpus(monkeypatch):
170
+ monkeypatch.setattr(quiet.os, "cpu_count", lambda: 16)
171
+ assert quiet.worker_threads(0) == 8 and quiet.worker_threads(5) == 5
172
+
173
+
174
+ def test_maintain_in_the_worker_does_the_work(monkeypatch):
175
+ from voiceio.cli import learn as cli
176
+ monkeypatch.setattr(cli, "_yield_cpu", lambda: None)
177
+ monkeypatch.setenv(quiet.WORKER_ENV, "1")
178
+ monkeypatch.setattr(quiet, "run_while_away", lambda *a, **k: pytest.fail("worker must not watch"))
179
+ ran = []
180
+ monkeypatch.setattr(cli, "_maintain", lambda args, cfg: ran.append(cfg.away_minutes))
181
+ cli.cmd_maintain(argparse.Namespace(min_new=5, base="small", epochs=1, when_idle=True))
182
+ assert ran == [10.0]
183
+
184
+
185
+ def test_scheduled_maintain_fails_when_its_work_does(monkeypatch):
186
+ from voiceio.cli import learn as cli
187
+ monkeypatch.setattr(cli, "_yield_cpu", lambda: None)
188
+ monkeypatch.delenv(quiet.WORKER_ENV, raising=False)
189
+ monkeypatch.setattr(quiet, "run_while_away", lambda *a, **k: 2)
190
+ with pytest.raises(SystemExit) as e:
191
+ cli.cmd_maintain(argparse.Namespace(min_new=5, base="small", epochs=1, when_idle=True))
192
+ assert e.value.code == 2
193
+
194
+
195
+ def test_scheduled_maintain_waits_for_a_quiet_machine(monkeypatch):
196
+ from voiceio.cli import learn as cli
197
+ monkeypatch.setattr(cli, "_yield_cpu", lambda: None)
198
+ monkeypatch.setattr(quiet, "wait_until_quiet", lambda *a, **k: False)
199
+ ran = []
200
+ monkeypatch.setattr(cli, "cmd_label", lambda a: ran.append("label"))
201
+ cli.cmd_maintain(argparse.Namespace(min_new=5, base="small", epochs=1, when_idle=True))
202
+ assert ran == [] # busy all night: nothing ran
203
+
204
+
205
+ def test_learning_announcement_offers_stop_and_weekly(monkeypatch, tmp_path):
206
+ from voiceio import feedback
207
+ from voiceio.cli import learn as cli
208
+ sent = []
209
+ monkeypatch.setattr(feedback, "notify_actions", lambda *a: sent.append(a))
210
+ monkeypatch.setattr(cli.store, "load_labels", lambda: {})
211
+ monkeypatch.setattr(cli.store, "clips", lambda: [argparse.Namespace(audio=f"a{i}.wav") for i in range(3)])
212
+ monkeypatch.setattr(cli.store, "latest_dataset", lambda: None)
213
+ cli._announce_learning(min_new=50)
214
+ (title, body, actions, _), = sent
215
+ assert "learning" in title and body.startswith("Labeling 3 new recordings,")
216
+ assert "while you're away" in body and set(actions) == {"stop", "weekly"}
217
+
218
+ sent.clear()
219
+ monkeypatch.setattr(cli.store, "load_labels", lambda: {f"a{i}.wav": 1 for i in range(3)})
220
+ monkeypatch.setattr(cli.store, "latest_dataset", lambda: tmp_path)
221
+ monkeypatch.setattr(cli.store, "dataset_clips", lambda _: {f"a{i}.wav" for i in range(3)})
222
+ cli._announce_learning(min_new=50)
223
+ assert sent == [] # nothing to do: no notification
224
+
225
+
226
+ def test_notify_actions_runs_the_picked_action(monkeypatch):
227
+ import subprocess
228
+
229
+ from voiceio import feedback
230
+ monkeypatch.setattr("sys.platform", "linux")
231
+ monkeypatch.setattr(feedback, "_which", lambda _: "/usr/bin/notify-send")
232
+ monkeypatch.setattr(feedback, "_notify_send_has_actions", lambda: True)
233
+ calls = []
234
+
235
+ def fake_run(args, **kw):
236
+ calls.append(args)
237
+ return subprocess.CompletedProcess(args, 0, stdout="weekly\n")
238
+ monkeypatch.setattr(feedback.subprocess, "run", fake_run)
239
+ picked = []
240
+
241
+ class Now: # run the waiting thread inline
242
+ def __init__(self, target, daemon): self.target = target
243
+ def start(self): self.target()
244
+ monkeypatch.setattr(feedback.threading, "Thread", Now)
245
+ feedback.notify_actions("t", "b", {"stop": "Stop", "weekly": "Weekly"}, picked.append)
246
+ assert picked == ["weekly"]
247
+ assert "--wait" in calls[0] and "--action=stop=Stop" in calls[0]
@@ -0,0 +1,243 @@
1
+ """A learning run that is stopped part-way carries on where it was."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import wave
7
+ from types import SimpleNamespace
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from voiceio import config
13
+ from voiceio.cli import learn as cli
14
+ from voiceio.learn import evaluate, store, train
15
+ from voiceio.learn.store import Segment
16
+
17
+
18
+ def _clip(name: str) -> None:
19
+ config.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
20
+ with wave.open(str(config.RECORDINGS_DIR / name), "wb") as w:
21
+ w.setnchannels(1)
22
+ w.setsampwidth(2)
23
+ w.setframerate(16000)
24
+ w.writeframes(np.zeros(3 * 16000, dtype=np.int16).tobytes())
25
+ config.HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
26
+ with open(config.HISTORY_PATH, "a") as f:
27
+ f.write(json.dumps({"ts": 1.0, "text": "x", "raw": "x", "duration": 3,
28
+ "audio": name}) + "\n")
29
+ store.add_label(name, [Segment(0, 3, "hello there")], "teacher:fake")
30
+
31
+
32
+ class Stopped(Exception):
33
+ """Stands in for the watcher ending the worker mid-stage."""
34
+
35
+
36
+ def _fake_work(monkeypatch, train_fn):
37
+ evals, trained = [], []
38
+ monkeypatch.setattr(cli, "cmd_label", lambda a: None)
39
+ monkeypatch.setattr(cli, "_announce_learning", lambda min_new: None)
40
+ monkeypatch.setattr(train, "missing_train_deps", lambda: [])
41
+
42
+ def run_eval(model, ds):
43
+ evals.append(model)
44
+ return SimpleNamespace(wer=0.1, term_recall=0.5, repetitions=0, per_clip=[])
45
+ monkeypatch.setattr(cli, "_run_eval", run_eval)
46
+ monkeypatch.setattr(cli, "_train", lambda ds, **k: trained.append(ds.name) or train_fn(ds))
47
+ monkeypatch.setattr(evaluate, "promotion_verdict", lambda c, b: (False, "table"))
48
+ return evals, trained
49
+
50
+
51
+ def test_a_stopped_retrain_carries_on_instead_of_waiting_for_more_clips(monkeypatch, capsys):
52
+ for i in range(3):
53
+ _clip(f"{i}.wav")
54
+ run = store.learn_dir() / "models" / "v0001-small-20261019-0822"
55
+
56
+ def stopped(ds):
57
+ raise Stopped
58
+ evals, trained = _fake_work(monkeypatch, stopped)
59
+ with pytest.raises(Stopped):
60
+ cli.cmd_maintain(argparse.Namespace(min_new=3, base="small", epochs=1))
61
+ assert trained == ["v0001"]
62
+
63
+ # Before: v0001 counted as done, so it waited for 3 more clips past it.
64
+ evals, trained = _fake_work(monkeypatch, lambda ds: run)
65
+ cli.cmd_maintain(argparse.Namespace(min_new=3, base="small", epochs=1))
66
+ out = capsys.readouterr().out
67
+ assert "carrying on with v0001" in out and trained == ["v0001"]
68
+ assert store.latest_dataset().name == "v0001" # no second dataset built
69
+
70
+ cli.cmd_maintain(argparse.Namespace(min_new=3, base="small", epochs=1))
71
+ assert "retraining waits for 3" in capsys.readouterr().out # decided: done with it
72
+
73
+
74
+ def test_resume_reuses_saved_scores_and_a_finished_model(monkeypatch):
75
+ for i in range(3):
76
+ _clip(f"{i}.wav")
77
+ ds = store.build_dataset()
78
+ cli._set_pending(ds)
79
+ run = store.learn_dir() / "models" / f"{ds.name}-small-20261019-0822"
80
+ (run / "ct2").mkdir(parents=True)
81
+ (run / "ct2" / "model.bin").write_text("")
82
+ current = config.load().model.name
83
+ saved = {current: {"wer": 0.2, "term_recall": 0.5, "repetitions": 0, "per_clip": []}}
84
+ monkeypatch.setattr(evaluate, "latest", lambda model, dataset, beam=5: saved.get(model))
85
+ evals, trained = _fake_work(monkeypatch, lambda ds: pytest.fail("already trained"))
86
+ cli.cmd_maintain(argparse.Namespace(min_new=50, base="small", epochs=1))
87
+ assert evals == [str(run / "ct2")] # only the candidate was still unscored
88
+ assert cli._pending_dataset() is None
89
+
90
+
91
+ def test_pending_marker_survives_only_with_its_dataset():
92
+ for i in range(2):
93
+ _clip(f"{i}.wav")
94
+ ds = store.build_dataset()
95
+ cli._set_pending(ds)
96
+ assert cli._pending_dataset() == ds
97
+ cli._set_pending(None)
98
+ assert cli._pending_dataset() is None
99
+ (store.learn_dir() / "maintain-pending.json").write_text('{"dataset": "v0099"}')
100
+ assert cli._pending_dataset() is None and not cli._pending_path().exists()
101
+
102
+
103
+ def test_a_half_built_dataset_is_never_the_latest(monkeypatch):
104
+ _clip("a.wav")
105
+ real = store.json.dumps
106
+ calls = []
107
+
108
+ def dies_at_manifest(obj, *a, **k):
109
+ if isinstance(obj, dict) and "schema" in obj:
110
+ raise Stopped
111
+ calls.append(1)
112
+ return real(obj, *a, **k)
113
+ monkeypatch.setattr(store.json, "dumps", dies_at_manifest)
114
+ with pytest.raises(Stopped):
115
+ store.build_dataset()
116
+ monkeypatch.setattr(store.json, "dumps", real)
117
+ assert store.latest_dataset() is None
118
+ assert store.build_dataset().name == "v0001"
119
+
120
+
121
+ def test_train_resumes_the_unfinished_run_of_the_same_dataset_and_base(monkeypatch):
122
+ for i in range(2):
123
+ _clip(f"{i}.wav")
124
+ ds = store.build_dataset()
125
+ folder = store.learn_dir() / "exports" / ds.name
126
+ folder.mkdir(parents=True)
127
+ for name in ("train.jsonl", "dev.jsonl", train.HOTWORDS_FILE):
128
+ (folder / name).write_text("")
129
+ models = store.learn_dir() / "models"
130
+ old = models / f"{ds.name}-small-20261018-0330" # finished before
131
+ (old / "ct2").mkdir(parents=True)
132
+ (old / "ct2" / "model.bin").write_text("")
133
+ stopped = models / f"{ds.name}-small-20261019-0822" # stopped mid-training
134
+ stopped.mkdir()
135
+ (stopped / train.CHECKPOINT).write_text("x")
136
+ (models / f"{ds.name}-small.en-20261019-0900").mkdir() # another base: not ours
137
+ got = {}
138
+ monkeypatch.setattr(train, "require_train_deps", lambda: None)
139
+ monkeypatch.setattr(train, "export", lambda *a, **k: pytest.fail("re-exported"))
140
+ monkeypatch.setattr(train, "train", lambda folder, out, cfg: got.update(out=out, cfg=cfg)
141
+ or out / "hf")
142
+
143
+ def convert(hf, out):
144
+ out.mkdir()
145
+ (out / "model.bin").write_text("")
146
+ monkeypatch.setattr(train, "convert", convert)
147
+ config.set_value("learn", "threads", 3)
148
+ assert cli._train(ds, base="small", epochs=1) == stopped and got["out"] == stopped
149
+ assert got["cfg"].threads == 3
150
+ assert not (stopped / train.CHECKPOINT).exists() # finished: resume state dropped
151
+ assert cli._finished_run(ds, "small") == stopped
152
+
153
+
154
+ # ── the training loop itself ─────────────────────────────────────────────
155
+
156
+ def _rows(n=10):
157
+ rng = np.random.default_rng(7)
158
+ return [{"audio": f"w{i}", "x": rng.normal(size=3).tolist(), "y": float(rng.normal())}
159
+ for i in range(n)]
160
+
161
+
162
+ def _fit(tmp_path, *, stop_at=None, checkpoint_secs=0.0, fp="fp"):
163
+ """fit() on a tiny model whose forward draws from mix_rng and dropout, so
164
+ every random state a resume must restore is in play."""
165
+ torch = pytest.importorskip("torch")
166
+ torch.manual_seed(0)
167
+ model = torch.nn.Sequential(torch.nn.Linear(3, 8), torch.nn.Dropout(0.3), torch.nn.Linear(8, 1))
168
+ mix_rng = np.random.default_rng(1)
169
+ calls = [0]
170
+
171
+ def forward(chunk, *, fixed=False):
172
+ calls[0] += 1
173
+ if stop_at is not None and calls[0] == stop_at:
174
+ raise Stopped
175
+ x = torch.tensor([r["x"] for r in chunk])
176
+ noise = 0.0 if fixed else float(mix_rng.random())
177
+ y = torch.tensor([[r["y"] + noise] for r in chunk])
178
+ return torch.nn.functional.mse_loss(model(x), y)
179
+ cfg = train.TrainConfig(epochs=3, batch_size=3, lr=1e-2)
180
+ rows = _rows()
181
+ best = train.fit(model, forward, rows[:7], rows[7:], cfg, checkpoint=tmp_path / "ck.pt",
182
+ fp=fp, order_rng=np.random.default_rng(0), mix_rng=mix_rng,
183
+ save_best=lambda: None, checkpoint_secs=checkpoint_secs)
184
+ return model, best, calls[0]
185
+
186
+
187
+ @pytest.mark.parametrize("stop_at", [2, 4, 6, 9]) # mid-epoch, at a dev pass, epoch 2, 3
188
+ def test_a_resumed_run_takes_exactly_the_steps_of_an_uninterrupted_one(tmp_path, stop_at):
189
+ torch = pytest.importorskip("torch")
190
+ (tmp_path / "a").mkdir()
191
+ whole, best_whole, calls_whole = _fit(tmp_path / "a")
192
+ assert calls_whole == 12 # 3 epochs × (3 train steps + 1 dev pass)
193
+ (tmp_path / "b").mkdir()
194
+ with pytest.raises(Stopped):
195
+ _fit(tmp_path / "b", stop_at=stop_at)
196
+ resumed, best_resumed, calls_resumed = _fit(tmp_path / "b")
197
+ # It carried on (at most the step in flight is redone), and ended where
198
+ # the uninterrupted run did, to the last bit of every random draw.
199
+ assert calls_resumed <= calls_whole - (stop_at - 1) + 1
200
+ for p, q in zip(whole.parameters(), resumed.parameters()):
201
+ assert torch.allclose(p, q, atol=1e-6)
202
+ assert best_resumed == pytest.approx(best_whole)
203
+
204
+
205
+ def test_progress_for_other_data_or_settings_starts_over(tmp_path, capsys):
206
+ (tmp_path / "a").mkdir()
207
+ with pytest.raises(Stopped):
208
+ _fit(tmp_path / "a", stop_at=4)
209
+ assert _fit(tmp_path / "a", fp="other")[2] == 12 # all of it again
210
+ assert "starting over" in capsys.readouterr().out
211
+ (tmp_path / "a" / "ck.pt").write_text("not a checkpoint")
212
+ _fit(tmp_path / "a") # unreadable: starts over, no crash
213
+
214
+
215
+ def test_fingerprint_follows_data_and_settings_not_threads(tmp_path):
216
+ for name in ("train.jsonl", "dev.jsonl"):
217
+ (tmp_path / name).write_text('{"audio": "a"}\n')
218
+ fp = train.fingerprint(tmp_path, train.TrainConfig())
219
+ assert train.fingerprint(tmp_path, train.TrainConfig(threads=3)) == fp
220
+ assert train.fingerprint(tmp_path, train.TrainConfig(epochs=9)) != fp
221
+ assert train.fingerprint(tmp_path, train.TrainConfig(), device="cuda") != fp
222
+ (tmp_path / "train.jsonl").write_text('{"audio": "b"}\n')
223
+ assert train.fingerprint(tmp_path, train.TrainConfig()) != fp
224
+
225
+
226
+ def test_encoder_cache_persists_across_runs_only_for_the_same_data(tmp_path):
227
+ keys = ["train/0.wav", "train/1.wav", "dev/0.wav"]
228
+ cache = train.EncoderCache(tmp_path / train.ENCODER_CACHE, keys, frames=4, width=2, fingerprint="a")
229
+ assert cache.missing(keys) == keys and cache.reused == 0
230
+ value = np.arange(8, dtype=np.float16).reshape(1, 4, 2)
231
+ cache.put(["train/1.wav"], value)
232
+ del cache
233
+
234
+ again = train.EncoderCache(tmp_path / train.ENCODER_CACHE, keys, frames=4, width=2, fingerprint="a")
235
+ assert again.reused == 1 and again.missing(keys) == ["train/0.wav", "dev/0.wav"]
236
+ np.testing.assert_array_equal(again.get(["train/1.wav"]), value)
237
+ del again
238
+
239
+ other = train.EncoderCache(tmp_path / train.ENCODER_CACHE, keys, frames=4, width=2, fingerprint="b")
240
+ assert other.missing(keys) == keys
241
+ del other
242
+ train.discard_resume_state(tmp_path)
243
+ assert not list(tmp_path.glob("encoder-cache*"))