python-voiceio 1.3.0__tar.gz → 1.4.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 (198) hide show
  1. {python_voiceio-1.3.0/python_voiceio.egg-info → python_voiceio-1.4.0}/PKG-INFO +11 -6
  2. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/README.md +9 -4
  3. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/pyproject.toml +2 -2
  4. {python_voiceio-1.3.0 → python_voiceio-1.4.0/python_voiceio.egg-info}/PKG-INFO +11 -6
  5. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/python_voiceio.egg-info/SOURCES.txt +2 -0
  6. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_health.py +1 -1
  7. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_pipeline.py +17 -0
  8. python_voiceio-1.4.0/tests/test_learn_quiet.py +102 -0
  9. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_service.py +25 -2
  10. python_voiceio-1.4.0/voiceio/__init__.py +1 -0
  11. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/learn.py +107 -7
  12. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/config.py +19 -0
  13. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/feedback.py +34 -0
  14. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/health.py +2 -3
  15. python_voiceio-1.4.0/voiceio/learn/quiet.py +54 -0
  16. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/service.py +49 -10
  17. python_voiceio-1.3.0/voiceio/__init__.py +0 -1
  18. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/LICENSE +0 -0
  19. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  20. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  21. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/python_voiceio.egg-info/requires.txt +0 -0
  22. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/python_voiceio.egg-info/top_level.txt +0 -0
  23. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/setup.cfg +0 -0
  24. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_app_wiring.py +0 -0
  25. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_audio_quality.py +0 -0
  26. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_audio_source.py +0 -0
  27. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_cli.py +0 -0
  29. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_clipboard_read.py +0 -0
  30. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_commands.py +0 -0
  31. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_concurrency_lockdown.py +0 -0
  32. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_config.py +0 -0
  33. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_corrections.py +0 -0
  34. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_decoders.py +0 -0
  35. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_engine.py +0 -0
  36. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_evdev_uaccess.py +0 -0
  37. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_fallback.py +0 -0
  38. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_fcitx_bridge.py +0 -0
  39. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_hints.py +0 -0
  40. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_history.py +0 -0
  41. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_engine_limits.py +0 -0
  42. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_engine_listener.py +0 -0
  43. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_pending.py +0 -0
  44. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_pids.py +0 -0
  45. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_ping.py +0 -0
  46. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_session.py +0 -0
  47. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_textlimit.py +0 -0
  48. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_ibus_typer.py +0 -0
  49. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_clean.py +0 -0
  50. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_import.py +0 -0
  51. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_metrics.py +0 -0
  52. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_review.py +0 -0
  53. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_learn_targets.py +0 -0
  54. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_llm.py +0 -0
  55. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_llm_api.py +0 -0
  56. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_numbers.py +0 -0
  57. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_pipeline.py +0 -0
  58. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_platform.py +0 -0
  59. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_postcorrect.py +0 -0
  60. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_postprocess.py +0 -0
  61. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_prebuffer.py +0 -0
  62. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_prompt.py +0 -0
  63. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_recorder_integration.py +0 -0
  64. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_retention.py +0 -0
  65. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_robustness.py +0 -0
  66. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_security_hardening.py +0 -0
  67. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_server.py +0 -0
  68. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_setup.py +0 -0
  69. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_streaming.py +0 -0
  70. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_suggest.py +0 -0
  71. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_tokens.py +0 -0
  72. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_transcriber.py +0 -0
  73. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_tts.py +0 -0
  74. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_typer_manager.py +0 -0
  75. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_vad.py +0 -0
  76. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_vocabulary.py +0 -0
  77. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_wordfreq.py +0 -0
  78. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/tests/test_worker_decode.py +0 -0
  79. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/__main__.py +0 -0
  80. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/app.py +0 -0
  81. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/audio_source.py +0 -0
  82. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/backends.py +0 -0
  83. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/__init__.py +0 -0
  84. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/configcmd.py +0 -0
  85. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/corrections.py +0 -0
  86. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/doctor.py +0 -0
  87. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/history.py +0 -0
  88. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/models.py +0 -0
  89. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/servicecmd.py +0 -0
  90. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/uninstall.py +0 -0
  91. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/cli/vocab.py +0 -0
  92. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/clipboard_read.py +0 -0
  93. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/commands.py +0 -0
  94. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/consent.py +0 -0
  95. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/__init__.py +0 -0
  96. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/bias.py +0 -0
  97. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/__init__.py +0 -0
  98. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/base.py +0 -0
  99. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/catalog.py +0 -0
  100. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  101. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  102. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/lanes.py +0 -0
  103. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/sherpa.py +0 -0
  104. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/decoders/whispercpp.py +0 -0
  105. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/engine.py +0 -0
  106. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/core/pipeline.py +0 -0
  107. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/corrections.py +0 -0
  108. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  109. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/data/__init__.py +0 -0
  110. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/demo.py +0 -0
  111. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/display.py +0 -0
  112. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hints.py +0 -0
  113. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/history.py +0 -0
  114. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/__init__.py +0 -0
  115. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/base.py +0 -0
  116. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/chain.py +0 -0
  117. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/evdev.py +0 -0
  118. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  119. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/hotkeys/socket_backend.py +0 -0
  120. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/__init__.py +0 -0
  121. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/engine.py +0 -0
  122. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/install.py +0 -0
  123. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/pending.py +0 -0
  124. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/session.py +0 -0
  125. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ibus/textlimit.py +0 -0
  126. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/__init__.py +0 -0
  127. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/augment.py +0 -0
  128. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/clean.py +0 -0
  129. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/evaluate.py +0 -0
  130. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/importer.py +0 -0
  131. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/metrics.py +0 -0
  132. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/review.py +0 -0
  133. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/store.py +0 -0
  134. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/teacher.py +0 -0
  135. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/learn/train.py +0 -0
  136. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/llm.py +0 -0
  137. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/llm_api.py +0 -0
  138. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/models/__init__.py +0 -0
  139. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/models/silero_vad.onnx +0 -0
  140. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/numbers.py +0 -0
  141. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/pidlock.py +0 -0
  142. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/platform.py +0 -0
  143. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/postcorrect.py +0 -0
  144. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/postprocess.py +0 -0
  145. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/privacy.py +0 -0
  146. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/prompt.py +0 -0
  147. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/recorder.py +0 -0
  148. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/retention.py +0 -0
  149. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/server/__init__.py +0 -0
  150. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/server/__main__.py +0 -0
  151. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/server/app.py +0 -0
  152. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/server/config.py +0 -0
  153. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/server/runtime.py +0 -0
  154. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/__init__.py +0 -0
  155. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/configfile.py +0 -0
  156. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/extras.py +0 -0
  157. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/hotkey.py +0 -0
  158. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/noninteractive.py +0 -0
  159. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/speech.py +0 -0
  160. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/steps.py +0 -0
  161. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/system.py +0 -0
  162. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/setup/ui.py +0 -0
  163. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/sounds/__init__.py +0 -0
  164. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/sounds/commit.wav +0 -0
  165. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/sounds/start.wav +0 -0
  166. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/sounds/stop.wav +0 -0
  167. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/streaming.py +0 -0
  168. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/suggest.py +0 -0
  169. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tokens.py +0 -0
  170. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tray/__init__.py +0 -0
  171. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tray/_icons.py +0 -0
  172. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tray/_indicator.py +0 -0
  173. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tray/_pystray.py +0 -0
  174. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/__init__.py +0 -0
  175. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/base.py +0 -0
  176. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/chain.py +0 -0
  177. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/edge_engine.py +0 -0
  178. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/espeak.py +0 -0
  179. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/piper_engine.py +0 -0
  180. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/tts/player.py +0 -0
  181. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/__init__.py +0 -0
  182. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/base.py +0 -0
  183. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/chain.py +0 -0
  184. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/clipboard.py +0 -0
  185. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/collecting.py +0 -0
  186. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/fcitx_bridge.py +0 -0
  187. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/ibus.py +0 -0
  188. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/manager.py +0 -0
  189. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/pynput_type.py +0 -0
  190. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/wtype.py +0 -0
  191. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/xdotool.py +0 -0
  192. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/typers/ydotool.py +0 -0
  193. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/ui.py +0 -0
  194. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/vad.py +0 -0
  195. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/vocab_stats.py +0 -0
  196. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/vocabulary.py +0 -0
  197. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/watchdog.py +0 -0
  198. {python_voiceio-1.3.0 → python_voiceio-1.4.0}/voiceio/wordfreq.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/Hugo0/voiceio
7
+ Project-URL: Homepage, https://voiceio.dev/
8
8
  Project-URL: Repository, https://github.com/Hugo0/voiceio
9
9
  Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
10
10
  Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
@@ -58,7 +58,7 @@ Dynamic: license-file
58
58
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
59
59
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
60
60
 
61
- **Voice dictation that learns you.** Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
61
+ **Voice dictation that learns you.** ([website](https://voiceio.dev/)) Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
62
62
 
63
63
  Off-the-shelf speech models stumble on exactly the words you say most: your colleagues' names, your project, your jargon. voiceio keeps (with your permission) the audio of what you dictate, has a bigger model transcribe it overnight, and trains a personal model from it — nothing uploaded, no GPU required. On the author's dictation, one overnight run on a laptop CPU cut word errors from **12.1% to 5.0%** on held-out clips and recognized more of the author's vocabulary, at the same speed. Every model is scored on clips it never trained on, and it only replaces the current one when it wins.
64
64
 
@@ -191,10 +191,16 @@ voiceio learn eval # score the current model on your held-out clips: WER a
191
191
  voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only on CPU, --full on a GPU)
192
192
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
193
193
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
194
- voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
195
- # (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
194
+ voiceio learn schedule on # …or let it run `maintain` on a schedule, and notify you when a model wins
195
+ # --at 02:00 --every week: when (default daily at 03:30)
196
+ # --auto-promote: switch to a winner by itself; `learn rollback` undoes it
197
+ voiceio learn stop # stop a run in progress (it goes again next time)
196
198
  ```
197
199
 
200
+ Scheduled runs wait for a quiet machine: they start only when the 1-minute load per CPU is at most `[learn] max_load` (0.25) and you're on mains power, and give up after `wait_hours` (3) until the next run. When one starts you get a notification with **Stop for now** and **Weekly instead**.
201
+
202
+ To keep a second machine (say, the server your phone dictates through) on the same model, set `[learn] on_promote = "scripts/push_model.sh HOST"`: after every switch it copies the model there and flips a `current` link the server points at.
203
+
198
204
  Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
199
205
 
200
206
  `voiceio learn status` shows where you are, each model's scores, and suggests `promote` only when a fine-tune wins on everything. `promote` itself refuses a model that hasn't, and reports how many clips got better or worse with a confidence interval — a few dozen test clips can move an average by luck.
@@ -302,7 +308,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
302
308
 
303
309
  ## Roadmap
304
310
 
305
- - **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
306
311
  - **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
307
312
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
308
313
 
@@ -5,7 +5,7 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
 
8
- **Voice dictation that learns you.** Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
8
+ **Voice dictation that learns you.** ([website](https://voiceio.dev/)) Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
9
9
 
10
10
  Off-the-shelf speech models stumble on exactly the words you say most: your colleagues' names, your project, your jargon. voiceio keeps (with your permission) the audio of what you dictate, has a bigger model transcribe it overnight, and trains a personal model from it — nothing uploaded, no GPU required. On the author's dictation, one overnight run on a laptop CPU cut word errors from **12.1% to 5.0%** on held-out clips and recognized more of the author's vocabulary, at the same speed. Every model is scored on clips it never trained on, and it only replaces the current one when it wins.
11
11
 
@@ -138,10 +138,16 @@ voiceio learn eval # score the current model on your held-out clips: WER a
138
138
  voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only on CPU, --full on a GPU)
139
139
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
140
140
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
141
- voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
142
- # (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
141
+ voiceio learn schedule on # …or let it run `maintain` on a schedule, and notify you when a model wins
142
+ # --at 02:00 --every week: when (default daily at 03:30)
143
+ # --auto-promote: switch to a winner by itself; `learn rollback` undoes it
144
+ voiceio learn stop # stop a run in progress (it goes again next time)
143
145
  ```
144
146
 
147
+ Scheduled runs wait for a quiet machine: they start only when the 1-minute load per CPU is at most `[learn] max_load` (0.25) and you're on mains power, and give up after `wait_hours` (3) until the next run. When one starts you get a notification with **Stop for now** and **Weekly instead**.
148
+
149
+ To keep a second machine (say, the server your phone dictates through) on the same model, set `[learn] on_promote = "scripts/push_model.sh HOST"`: after every switch it copies the model there and flips a `current` link the server points at.
150
+
145
151
  Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
146
152
 
147
153
  `voiceio learn status` shows where you are, each model's scores, and suggests `promote` only when a fine-tune wins on everything. `promote` itself refuses a model that hasn't, and reports how many clips got better or worse with a confidence interval — a few dozen test clips can move an average by luck.
@@ -249,7 +255,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
249
255
 
250
256
  ## Roadmap
251
257
 
252
- - **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
253
258
  - **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
254
259
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
255
260
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.3.0"
7
+ version = "1.4.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -58,7 +58,7 @@ sherpa = ["sherpa-onnx>=1.12"]
58
58
  dev = ["pytest>=7.0", "pytest-mock", "ruff>=0.15,<0.18"]
59
59
 
60
60
  [project.urls]
61
- Homepage = "https://github.com/Hugo0/voiceio"
61
+ Homepage = "https://voiceio.dev/"
62
62
  Repository = "https://github.com/Hugo0/voiceio"
63
63
  Issues = "https://github.com/Hugo0/voiceio/issues"
64
64
  Changelog = "https://github.com/Hugo0/voiceio/releases"
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/Hugo0/voiceio
7
+ Project-URL: Homepage, https://voiceio.dev/
8
8
  Project-URL: Repository, https://github.com/Hugo0/voiceio
9
9
  Project-URL: Issues, https://github.com/Hugo0/voiceio/issues
10
10
  Project-URL: Changelog, https://github.com/Hugo0/voiceio/releases
@@ -58,7 +58,7 @@ Dynamic: license-file
58
58
  [![Python](https://img.shields.io/pypi/pyversions/python-voiceio)](https://pypi.org/project/python-voiceio/)
59
59
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
60
60
 
61
- **Voice dictation that learns you.** Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
61
+ **Voice dictation that learns you.** ([website](https://voiceio.dev/)) Local, open source, private — and it fine-tunes itself on your own voice, on your own machine.
62
62
 
63
63
  Off-the-shelf speech models stumble on exactly the words you say most: your colleagues' names, your project, your jargon. voiceio keeps (with your permission) the audio of what you dictate, has a bigger model transcribe it overnight, and trains a personal model from it — nothing uploaded, no GPU required. On the author's dictation, one overnight run on a laptop CPU cut word errors from **12.1% to 5.0%** on held-out clips and recognized more of the author's vocabulary, at the same speed. Every model is scored on clips it never trained on, and it only replaces the current one when it wins.
64
64
 
@@ -191,10 +191,16 @@ voiceio learn eval # score the current model on your held-out clips: WER a
191
191
  voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only on CPU, --full on a GPU)
192
192
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
193
193
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
194
- voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
195
- # (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
194
+ voiceio learn schedule on # …or let it run `maintain` on a schedule, and notify you when a model wins
195
+ # --at 02:00 --every week: when (default daily at 03:30)
196
+ # --auto-promote: switch to a winner by itself; `learn rollback` undoes it
197
+ voiceio learn stop # stop a run in progress (it goes again next time)
196
198
  ```
197
199
 
200
+ Scheduled runs wait for a quiet machine: they start only when the 1-minute load per CPU is at most `[learn] max_load` (0.25) and you're on mains power, and give up after `wait_hours` (3) until the next run. When one starts you get a notification with **Stop for now** and **Weekly instead**.
201
+
202
+ To keep a second machine (say, the server your phone dictates through) on the same model, set `[learn] on_promote = "scripts/push_model.sh HOST"`: after every switch it copies the model there and flips a `current` link the server points at.
203
+
198
204
  Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
199
205
 
200
206
  `voiceio learn status` shows where you are, each model's scores, and suggests `promote` only when a fine-tune wins on everything. `promote` itself refuses a model that hasn't, and reports how many clips got better or worse with a confidence interval — a few dozen test clips can move an average by luck.
@@ -302,7 +308,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
302
308
 
303
309
  ## Roadmap
304
310
 
305
- - **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
306
311
  - **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
307
312
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
308
313
 
@@ -37,6 +37,7 @@ tests/test_learn_clean.py
37
37
  tests/test_learn_import.py
38
38
  tests/test_learn_metrics.py
39
39
  tests/test_learn_pipeline.py
40
+ tests/test_learn_quiet.py
40
41
  tests/test_learn_review.py
41
42
  tests/test_learn_targets.py
42
43
  tests/test_llm.py
@@ -144,6 +145,7 @@ voiceio/learn/clean.py
144
145
  voiceio/learn/evaluate.py
145
146
  voiceio/learn/importer.py
146
147
  voiceio/learn/metrics.py
148
+ voiceio/learn/quiet.py
147
149
  voiceio/learn/review.py
148
150
  voiceio/learn/store.py
149
151
  voiceio/learn/teacher.py
@@ -121,5 +121,5 @@ def test_learning_status_and_tips(monkeypatch):
121
121
  cfg.model.name = str(learn_dir() / "models" / "v0001-small-x" / "ct2")
122
122
  st = _learning_status(cfg)
123
123
  assert st["Voice model"] == "fine-tuned on you (v0001-small-x)"
124
- assert st["Recordings"].startswith("on (") and st["Nightly learning"] == "off"
124
+ assert st["Recordings"].startswith("on (") and st["Scheduled learning"] == "off"
125
125
  assert any("learn schedule on" in t for t in _accuracy_tips(st))
@@ -343,6 +343,7 @@ def test_maintain_auto_promote_switches_restarts_and_says_how_to_undo(monkeypatc
343
343
  run = store.learn_dir() / "models" / "v0001-small-x"
344
344
  calls = []
345
345
  monkeypatch.setattr(cli, "cmd_label", lambda a: None)
346
+ monkeypatch.setattr(cli, "_announce_learning", lambda min_new: None)
346
347
  monkeypatch.setattr(train, "missing_train_deps", lambda: [])
347
348
  monkeypatch.setattr(cli, "_run_eval", lambda *a, **k: SimpleNamespace(
348
349
  wer=0.1, term_recall=0.5, repetitions=0, per_clip=[]))
@@ -366,3 +367,19 @@ def test_maintain_auto_promote_switches_restarts_and_says_how_to_undo(monkeypatc
366
367
  monkeypatch.setattr(cli, "cmd_promote", refuse)
367
368
  cli.cmd_maintain(argparse.Namespace(min_new=1, base="small", epochs=1, auto_promote=True))
368
369
  assert calls == []
370
+
371
+
372
+ def test_promote_and_rollback_run_on_promote_with_the_model(monkeypatch, tmp_path):
373
+ import argparse
374
+
375
+ from voiceio import config
376
+ from voiceio.cli import learn as cli
377
+ run = store.learn_dir() / "models" / "v0002-small-x" / "ct2"
378
+ run.mkdir(parents=True)
379
+ (run / "model.bin").write_text("")
380
+ out = tmp_path / "hook.txt"
381
+ config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
382
+ config.CONFIG_PATH.write_text(f'[learn]\non_promote = "echo $VOICEIO_MODEL >> {out}"\n')
383
+ cli.cmd_promote(argparse.Namespace(run="v0002-small-x", force=True))
384
+ cli.cmd_rollback(argparse.Namespace())
385
+ assert out.read_text().split() == [str(run), "small"]
@@ -0,0 +1,102 @@
1
+ import argparse
2
+
3
+ from voiceio.learn import quiet
4
+
5
+
6
+ def _supply(root, name, kind, online):
7
+ d = root / name
8
+ d.mkdir()
9
+ (d / "type").write_text(kind + "\n")
10
+ (d / "online").write_text(online + "\n")
11
+
12
+
13
+ def test_on_battery_needs_a_mains_supply_that_is_offline(tmp_path):
14
+ assert not quiet.on_battery(tmp_path) # desktop: no supplies listed
15
+ _supply(tmp_path, "BAT0", "Battery", "0")
16
+ _supply(tmp_path, "AC", "Mains", "0")
17
+ assert quiet.on_battery(tmp_path)
18
+ (tmp_path / "AC" / "online").write_text("1\n")
19
+ assert not quiet.on_battery(tmp_path)
20
+
21
+
22
+ def test_busy_reason_is_load_per_cpu_then_battery(monkeypatch):
23
+ monkeypatch.setattr(quiet.os, "cpu_count", lambda: 8)
24
+ monkeypatch.setattr(quiet, "on_battery", lambda: False)
25
+ monkeypatch.setattr(quiet.os, "getloadavg", lambda: (4.0, 0, 0)) # 0.5 per CPU
26
+ assert "load 0.50 per CPU" in quiet.busy_reason(0.25)
27
+ monkeypatch.setattr(quiet.os, "getloadavg", lambda: (1.0, 0, 0))
28
+ assert quiet.busy_reason(0.25) is None
29
+ monkeypatch.setattr(quiet, "on_battery", lambda: True)
30
+ assert quiet.busy_reason(0.25) == "on battery"
31
+
32
+
33
+ def test_wait_until_quiet_waits_then_gives_up(monkeypatch):
34
+ now = [0.0]
35
+ sleeps = []
36
+ reasons = iter(["busy", "busy", None])
37
+ monkeypatch.setattr(quiet, "busy_reason", lambda _: next(reasons))
38
+ args = dict(poll=300, sleep=lambda s: (sleeps.append(s), now.__setitem__(0, now[0] + s)),
39
+ clock=lambda: now[0], say=lambda _: None)
40
+ assert quiet.wait_until_quiet(0.25, 3, **args)
41
+ assert sleeps == [300, 300]
42
+
43
+ monkeypatch.setattr(quiet, "busy_reason", lambda _: "on battery")
44
+ said = []
45
+ now[0] = 0.0
46
+ assert not quiet.wait_until_quiet(0.25, 1, **{**args, "say": said.append})
47
+ assert now[0] <= 3600 and said[-1].startswith("still on battery after 1 h")
48
+
49
+
50
+ def test_scheduled_maintain_waits_for_a_quiet_machine(monkeypatch):
51
+ from voiceio.cli import learn as cli
52
+ monkeypatch.setattr(cli, "_yield_cpu", lambda: None)
53
+ monkeypatch.setattr(quiet, "wait_until_quiet", lambda *a, **k: False)
54
+ ran = []
55
+ monkeypatch.setattr(cli, "cmd_label", lambda a: ran.append("label"))
56
+ cli.cmd_maintain(argparse.Namespace(min_new=5, base="small", epochs=1, when_idle=True))
57
+ assert ran == [] # busy all night: nothing ran
58
+
59
+
60
+ def test_learning_announcement_offers_stop_and_weekly(monkeypatch, tmp_path):
61
+ from voiceio import feedback
62
+ from voiceio.cli import learn as cli
63
+ sent = []
64
+ monkeypatch.setattr(feedback, "notify_actions", lambda *a: sent.append(a))
65
+ monkeypatch.setattr(cli.store, "load_labels", lambda: {})
66
+ monkeypatch.setattr(cli.store, "clips", lambda: [argparse.Namespace(audio=f"a{i}.wav") for i in range(3)])
67
+ monkeypatch.setattr(cli.store, "latest_dataset", lambda: None)
68
+ cli._announce_learning(min_new=50)
69
+ (title, body, actions, _), = sent
70
+ assert "learning" in title and body.startswith("Labeling 3 new recordings,")
71
+ assert "on this computer" in body and set(actions) == {"stop", "weekly"}
72
+
73
+ sent.clear()
74
+ monkeypatch.setattr(cli.store, "load_labels", lambda: {f"a{i}.wav": 1 for i in range(3)})
75
+ monkeypatch.setattr(cli.store, "latest_dataset", lambda: tmp_path)
76
+ monkeypatch.setattr(cli.store, "dataset_clips", lambda _: {f"a{i}.wav" for i in range(3)})
77
+ cli._announce_learning(min_new=50)
78
+ assert sent == [] # nothing to do: no notification
79
+
80
+
81
+ def test_notify_actions_runs_the_picked_action(monkeypatch):
82
+ import subprocess
83
+
84
+ from voiceio import feedback
85
+ monkeypatch.setattr("sys.platform", "linux")
86
+ monkeypatch.setattr(feedback, "_which", lambda _: "/usr/bin/notify-send")
87
+ monkeypatch.setattr(feedback, "_notify_send_has_actions", lambda: True)
88
+ calls = []
89
+
90
+ def fake_run(args, **kw):
91
+ calls.append(args)
92
+ return subprocess.CompletedProcess(args, 0, stdout="weekly\n")
93
+ monkeypatch.setattr(feedback.subprocess, "run", fake_run)
94
+ picked = []
95
+
96
+ class Now: # run the waiting thread inline
97
+ def __init__(self, target, daemon): self.target = target
98
+ def start(self): self.target()
99
+ monkeypatch.setattr(feedback.threading, "Thread", Now)
100
+ feedback.notify_actions("t", "b", {"stop": "Stop", "weekly": "Weekly"}, picked.append)
101
+ assert picked == ["weekly"]
102
+ assert "--wait" in calls[0] and "--action=stop=Stop" in calls[0]
@@ -36,7 +36,7 @@ def test_learn_timer_runs_maintain_at_idle_priority(monkeypatch, tmp_path):
36
36
 
37
37
  assert service.install_learn_timer()
38
38
  unit = service.LEARN_SERVICE_PATH.read_text()
39
- assert "ExecStart=/opt/v/bin/voiceio learn maintain" in unit
39
+ assert "ExecStart=/opt/v/bin/voiceio learn maintain --when-idle" in unit
40
40
  assert "CPUWeight=idle" in unit and "VOICEIO_IDLE_SCOPE=1" in unit
41
41
  assert "Persistent=true" in service.LEARN_TIMER_PATH.read_text()
42
42
  assert ("enable", "--now", service.LEARN_TIMER_NAME) in calls
@@ -89,5 +89,28 @@ def test_learn_timer_auto_promote_is_opt_in(monkeypatch, tmp_path):
89
89
  service.install_learn_timer()
90
90
  assert not service.learn_timer_auto_promotes()
91
91
  service.install_learn_timer(auto_promote=True)
92
- assert "learn maintain --auto-promote" in service.LEARN_SERVICE_PATH.read_text()
92
+ assert "learn maintain --when-idle --auto-promote" in service.LEARN_SERVICE_PATH.read_text()
93
93
  assert service.learn_timer_auto_promotes()
94
+
95
+
96
+ def test_learn_schedule_comes_from_config(monkeypatch, tmp_path):
97
+ import pytest
98
+
99
+ from voiceio import config
100
+ monkeypatch.setattr(service, "has_systemd", lambda: True)
101
+ monkeypatch.setattr(service, "_systemctl", lambda *a: True)
102
+ monkeypatch.setattr(service, "_find_voiceio_bin", lambda: "voiceio")
103
+ monkeypatch.setattr(service, "LEARN_SERVICE_PATH", tmp_path / "s.service")
104
+ monkeypatch.setattr(service, "LEARN_TIMER_PATH", tmp_path / "s.timer")
105
+ service.install_learn_timer()
106
+ assert "OnCalendar=*-*-* 03:30" in service.LEARN_TIMER_PATH.read_text()
107
+
108
+ config.set_value("learn", "at", "2:05")
109
+ config.set_value("learn", "every", "week")
110
+ service.install_learn_timer()
111
+ assert "OnCalendar=Sun *-*-* 02:05" in service.LEARN_TIMER_PATH.read_text()
112
+ assert service.learn_schedule_summary() == "weekly on Sunday at 2:05"
113
+
114
+ for at, every in (("25:00", "day"), ("3pm", "day"), ("03:30", "month")):
115
+ with pytest.raises(ValueError):
116
+ service.learn_calendar(at, every)
@@ -0,0 +1 @@
1
+ __version__ = "1.4.0"
@@ -86,14 +86,23 @@ def register(sub: argparse._SubParsersAction) -> None:
86
86
  mt.add_argument("--epochs", type=int, default=4)
87
87
  mt.add_argument("--auto-promote", action="store_true",
88
88
  help="switch to the fine-tune when it wins, and restart the daemon")
89
+ mt.add_argument("--when-idle", action="store_true",
90
+ help="first wait for a quiet machine on mains power ([learn] max_load, "
91
+ "wait_hours); the scheduled run uses this")
89
92
  mt.set_defaults(func=cmd_maintain)
90
93
 
91
- sc = lsub.add_parser("schedule", help="Run `maintain` every night (systemd timer)")
94
+ sc = lsub.add_parser("schedule", help="Run `maintain` on a schedule (systemd timer)")
92
95
  sc.add_argument("state", nargs="?", choices=("on", "off"), help="omit to show")
96
+ sc.add_argument("--at", help="time of day, e.g. 02:00 (saved as [learn] at; default 03:30)")
97
+ sc.add_argument("--every", choices=("day", "week"),
98
+ help="daily, or weekly on Sunday (saved as [learn] every)")
93
99
  sc.add_argument("--auto-promote", action="store_true",
94
100
  help="with on: switch to a fine-tune that wins, without asking")
95
101
  sc.set_defaults(func=cmd_schedule)
96
102
 
103
+ st = lsub.add_parser("stop", help="Stop a scheduled learning run that is in progress")
104
+ st.set_defaults(func=cmd_stop)
105
+
97
106
  pr = lsub.add_parser("promote", help="Use a trained model for dictation")
98
107
  pr.add_argument("run", help="run name under learn/models (see `learn status`)")
99
108
  pr.add_argument("--force", action="store_true",
@@ -433,7 +442,12 @@ def cmd_maintain(args: argparse.Namespace) -> None:
433
442
  """Label what's new; once enough has piled up, rebuild, retrain and say
434
443
  whether the result beats the current model. Never promotes by itself."""
435
444
  _yield_cpu()
436
- from voiceio.learn import evaluate
445
+ from voiceio.learn import evaluate, quiet
446
+ learn_cfg = _cfg().learn
447
+ if getattr(args, "when_idle", False) and not quiet.wait_until_quiet(
448
+ learn_cfg.max_load, learn_cfg.wait_hours):
449
+ return
450
+ _announce_learning(args.min_new)
437
451
  cmd_label(argparse.Namespace(teacher=None, limit=None, relabel=False))
438
452
  from voiceio.learn import train
439
453
  if missing := train.missing_train_deps():
@@ -484,15 +498,75 @@ def cmd_maintain(args: argparse.Namespace) -> None:
484
498
  urgent=True)
485
499
 
486
500
 
501
+ def _announce_learning(min_new: int) -> None:
502
+ """Tell the user a run is starting (it takes a while and uses the CPU),
503
+ with buttons to stop it or to learn weekly instead. Silent when there is
504
+ nothing to do."""
505
+ labels = store.load_labels()
506
+ clips = [c.audio for c in store.clips()]
507
+ to_label = sum(1 for a in clips if a not in labels)
508
+ ds = store.latest_dataset()
509
+ new = len(set(clips) - (store.dataset_clips(ds) if ds else set()))
510
+ retrain = new >= min_new
511
+ if not to_label and not retrain:
512
+ return
513
+ from voiceio import feedback
514
+ what = (f"Labeling {to_label} new recordings" if to_label else "") + \
515
+ (", then retraining" if to_label and retrain else "Retraining" if retrain else "")
516
+ feedback.notify_actions(
517
+ "VoiceIO is learning your voice",
518
+ f"{what}, on this computer at idle priority; dictation stays fast.",
519
+ {"stop": "Stop for now", "weekly": "Weekly instead"},
520
+ _on_learning_action)
521
+
522
+
523
+ def _on_learning_action(key: str) -> None:
524
+ import signal
525
+
526
+ from voiceio import config, feedback, service
527
+ if key == "weekly":
528
+ config.set_value("learn", "every", "week")
529
+ service.install_learn_timer(auto_promote=service.learn_timer_auto_promotes())
530
+ feedback.notify("VoiceIO: learning weekly now",
531
+ f"{service.learn_schedule_summary()}. This run continues; "
532
+ "change it with voiceio learn schedule on --every day")
533
+ elif key == "stop":
534
+ # Under the timer, stop the unit so training subprocesses go too.
535
+ if not (os.environ.get("INVOCATION_ID") and service.stop_learning()):
536
+ os.kill(os.getpid(), signal.SIGINT)
537
+
538
+
487
539
  def _schedule_state() -> str:
488
540
  from voiceio import service
489
- if not service.learn_timer_enabled():
490
- return "off (voiceio learn schedule on)"
491
- return "on (auto-promote)" if service.learn_timer_auto_promotes() else "on"
541
+ summary = service.learn_schedule_summary()
542
+ return "off (voiceio learn schedule on)" if summary == "off" else f"on, {summary}"
543
+
544
+
545
+ def cmd_stop(args: argparse.Namespace) -> None:
546
+ from voiceio import service
547
+ if not service.learning_now():
548
+ print("no learning run in progress")
549
+ elif service.stop_learning():
550
+ print("stopped; it runs again at the next scheduled time")
551
+ else:
552
+ raise SystemExit("could not stop voiceio-learn.service")
492
553
 
493
554
 
494
555
  def cmd_schedule(args: argparse.Namespace) -> None:
495
556
  from voiceio import config, service
557
+ if args.at or args.every:
558
+ learn = config.load().learn
559
+ try: # validate before saving: the timer is generated from these
560
+ service.learn_calendar(args.at or learn.at, args.every or learn.every)
561
+ except ValueError as e:
562
+ raise SystemExit(str(e)) from None
563
+ for key in ("at", "every"):
564
+ if getattr(args, key):
565
+ config.set_value("learn", key, getattr(args, key))
566
+ if args.state is None and service.learn_timer_enabled(): # reschedule in place
567
+ service.install_learn_timer(auto_promote=service.learn_timer_auto_promotes())
568
+ print(f"on, {service.learn_schedule_summary()}")
569
+ return
496
570
  if args.state == "on":
497
571
  if not config.load().data.retain_audio:
498
572
  print("note: [data] retain_audio is off, so there is nothing new to learn from;\n"
@@ -503,8 +577,11 @@ def cmd_schedule(args: argparse.Namespace) -> None:
503
577
  f" to also retrain: {train.TRAIN_HINT}")
504
578
  if not service.install_learn_timer(auto_promote=args.auto_promote):
505
579
  raise SystemExit("could not enable the timer (needs systemd --user)")
506
- print("on: every night ~03:30, `voiceio learn maintain` labels new dictation at idle "
507
- "priority, retrains once 50+ clips are new, and "
580
+ learn = config.load().learn
581
+ print(f"on, {service.learn_schedule_summary()}: once the machine is quiet (load per CPU "
582
+ f"≤ {learn.max_load:g}, on mains power; waits up to {learn.wait_hours:g} h), "
583
+ "`voiceio learn maintain` labels new dictation at idle priority, retrains once "
584
+ "50+ clips are new, and "
508
585
  + ("switches to the result when it wins on your held-out clips "
509
586
  "(undo: voiceio learn rollback)." if args.auto_promote else
510
587
  "tells you if the result is better. It never switches models by itself."))
@@ -532,6 +609,28 @@ def cmd_promote(args: argparse.Namespace) -> None:
532
609
  f.write(json.dumps({"ts": time.time(), "from": _cfg().model.name, "to": str(ct2)}) + "\n")
533
610
  set_value("model", "name", str(ct2))
534
611
  print(f"now dictating with {args.run} — restart the daemon: voiceio service restart")
612
+ _on_promote(str(ct2))
613
+
614
+
615
+ def _on_promote(model: str) -> None:
616
+ """Run `[learn] on_promote` for a model switch. Its failure is reported,
617
+ never fatal: the switch on this machine already happened."""
618
+ import os
619
+ import subprocess
620
+ cmd = _cfg().learn.on_promote
621
+ if not cmd:
622
+ return
623
+ print(f"on_promote: {cmd}", flush=True)
624
+ try:
625
+ rc = subprocess.run(cmd, shell=True, env={**os.environ, "VOICEIO_MODEL": model},
626
+ timeout=3600).returncode
627
+ except subprocess.TimeoutExpired:
628
+ rc = "timeout"
629
+ if rc != 0:
630
+ print(f"on_promote failed ({rc})")
631
+ from voiceio import feedback
632
+ feedback.notify("VoiceIO: model switched here, but on_promote failed",
633
+ f"`{cmd}` exited {rc}; run it again by hand.")
535
634
 
536
635
 
537
636
  def _check_beats_current(ct2: Path) -> None:
@@ -563,3 +662,4 @@ def cmd_rollback(args: argparse.Namespace) -> None:
563
662
  set_value("model", "name", last["from"])
564
663
  history.write_text("".join(r + "\n" for r in rows[:-1]))
565
664
  print(f"back to {last['from']} — restart the daemon: voiceio service restart")
665
+ _on_promote(last["from"])
@@ -243,6 +243,23 @@ class DataConfig:
243
243
  capture_intermediates: bool = False
244
244
 
245
245
 
246
+ @dataclass
247
+ class LearnConfig:
248
+ # A command run after every model switch (promote, auto-promote,
249
+ # rollback), with the model in $VOICEIO_MODEL — e.g. to push a fine-tune
250
+ # to another machine: scripts/push_model.sh HOST. Empty: nothing runs.
251
+ on_promote: str = ""
252
+ # When nightly learning runs (`voiceio learn schedule on`): a time of day,
253
+ # and "day" or "week" (weekly runs on Sunday).
254
+ at: str = "03:30"
255
+ every: str = "day"
256
+ # It only starts on a quiet machine on mains power: 1-minute load per CPU
257
+ # at most max_load. It waits up to wait_hours for that, then tries again
258
+ # at the next scheduled run.
259
+ max_load: float = 0.25
260
+ wait_hours: float = 3.0
261
+
262
+
246
263
  @dataclass
247
264
  class Config:
248
265
  hotkey: HotkeyConfig = field(default_factory=HotkeyConfig)
@@ -260,6 +277,7 @@ class Config:
260
277
  health: HealthConfig = field(default_factory=HealthConfig)
261
278
  data: DataConfig = field(default_factory=DataConfig)
262
279
  history: HistoryConfig = field(default_factory=HistoryConfig)
280
+ learn: LearnConfig = field(default_factory=LearnConfig)
263
281
 
264
282
 
265
283
  _REMOVED_KEYS = {
@@ -340,6 +358,7 @@ def load(path: Path | None = None) -> Config:
340
358
  health=_build(HealthConfig, raw.get("health", {})),
341
359
  data=_build(DataConfig, raw.get("data", {})),
342
360
  history=_build(HistoryConfig, raw.get("history", {})),
361
+ learn=_build(LearnConfig, raw.get("learn", {})),
343
362
  )
344
363
 
345
364
 
@@ -14,6 +14,7 @@ import shutil
14
14
  import subprocess
15
15
  import threading
16
16
  import wave
17
+ from collections.abc import Callable
17
18
  from pathlib import Path
18
19
 
19
20
  import numpy as np
@@ -151,6 +152,39 @@ def _send_notification_linux(title: str, body: str, urgent: bool = False) -> Non
151
152
  pass
152
153
 
153
154
 
155
+ def notify_actions(title: str, body: str, actions: dict[str, str],
156
+ on_action: Callable[[str], None]) -> None:
157
+ """A notification with buttons ({key: label}); ``on_action(key)`` runs
158
+ when one is clicked. Linux only; elsewhere, or with a notify-send too old
159
+ for actions, it degrades to a plain notification. Never blocks."""
160
+ import sys
161
+ if sys.platform != "linux" or not _which("notify-send") or not _notify_send_has_actions():
162
+ notify(title, body)
163
+ return
164
+
165
+ def wait() -> None:
166
+ args = ["notify-send", "--app-name=VoiceIO", "--wait",
167
+ *(f"--action={k}={label}" for k, label in actions.items()), title, body]
168
+ try:
169
+ picked = subprocess.run(args, capture_output=True, text=True).stdout.strip()
170
+ except OSError:
171
+ return
172
+ if picked in actions:
173
+ on_action(picked)
174
+
175
+ threading.Thread(target=wait, daemon=True).start()
176
+
177
+
178
+ @functools.lru_cache(maxsize=1)
179
+ def _notify_send_has_actions() -> bool:
180
+ """--action/--wait arrived in libnotify 0.7.9."""
181
+ try:
182
+ return "--action" in subprocess.run(["notify-send", "--help"], capture_output=True,
183
+ text=True, timeout=3).stdout
184
+ except (subprocess.TimeoutExpired, OSError):
185
+ return False
186
+
187
+
154
188
  def _send_notification_windows(title: str, body: str) -> None:
155
189
  try:
156
190
  from win11toast import notify
@@ -247,8 +247,7 @@ def _learning_status(cfg) -> dict[str, str]:
247
247
  out["Recordings"] = f"on ({n} kept to learn from)"
248
248
  else:
249
249
  out["Recordings"] = "off (nothing to learn from)"
250
- out["Nightly learning"] = ("off" if not service.learn_timer_enabled() else
251
- "on (auto-promote)" if service.learn_timer_auto_promotes() else "on")
250
+ out["Scheduled learning"] = service.learn_schedule_summary()
252
251
  return out
253
252
 
254
253
 
@@ -354,7 +353,7 @@ def _accuracy_tips(accuracy: dict[str, str]) -> list[str]:
354
353
  tips.append("Add names/jargon to ~/.config/voiceio/vocabulary.txt")
355
354
  if accuracy.get("Recordings", "").startswith("off"):
356
355
  tips.append("voiceio config set data.retain_audio true — keep recordings so voiceio can learn your voice")
357
- elif accuracy.get("Nightly learning") == "off":
356
+ elif accuracy.get("Scheduled learning") == "off":
358
357
  tips.append("voiceio learn schedule on — retrain on your voice every night")
359
358
  return tips
360
359