python-voiceio 1.2.0__tar.gz → 1.3.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 (197) hide show
  1. {python_voiceio-1.2.0/python_voiceio.egg-info → python_voiceio-1.3.0}/PKG-INFO +4 -3
  2. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/README.md +3 -2
  3. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/pyproject.toml +1 -1
  4. {python_voiceio-1.2.0 → python_voiceio-1.3.0/python_voiceio.egg-info}/PKG-INFO +4 -3
  5. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_pipeline.py +61 -3
  6. python_voiceio-1.3.0/tests/test_learn_review.py +44 -0
  7. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_service.py +36 -5
  8. python_voiceio-1.3.0/voiceio/__init__.py +1 -0
  9. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/app.py +8 -1
  10. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/learn.py +48 -16
  11. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/servicecmd.py +29 -17
  12. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/health.py +2 -1
  13. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/evaluate.py +15 -5
  14. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/review.py +30 -9
  15. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/pidlock.py +18 -0
  16. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/service.py +19 -4
  17. python_voiceio-1.2.0/tests/test_learn_review.py +0 -20
  18. python_voiceio-1.2.0/voiceio/__init__.py +0 -1
  19. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/LICENSE +0 -0
  20. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  21. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  22. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  23. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/requires.txt +0 -0
  24. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/top_level.txt +0 -0
  25. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/setup.cfg +0 -0
  26. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_app_wiring.py +0 -0
  27. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_audio_quality.py +0 -0
  28. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_audio_source.py +0 -0
  29. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_backend_probes.py +0 -0
  30. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_cli.py +0 -0
  31. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_clipboard_read.py +0 -0
  32. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_commands.py +0 -0
  33. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_concurrency_lockdown.py +0 -0
  34. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_config.py +0 -0
  35. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_corrections.py +0 -0
  36. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_decoders.py +0 -0
  37. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_engine.py +0 -0
  38. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_evdev_uaccess.py +0 -0
  39. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_fallback.py +0 -0
  40. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_fcitx_bridge.py +0 -0
  41. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_health.py +0 -0
  42. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_hints.py +0 -0
  43. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_history.py +0 -0
  44. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_engine_limits.py +0 -0
  45. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_engine_listener.py +0 -0
  46. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_pending.py +0 -0
  47. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_pids.py +0 -0
  48. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_ping.py +0 -0
  49. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_session.py +0 -0
  50. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_textlimit.py +0 -0
  51. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_typer.py +0 -0
  52. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_clean.py +0 -0
  53. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_import.py +0 -0
  54. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_metrics.py +0 -0
  55. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_targets.py +0 -0
  56. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_llm.py +0 -0
  57. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_llm_api.py +0 -0
  58. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_numbers.py +0 -0
  59. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_pipeline.py +0 -0
  60. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_platform.py +0 -0
  61. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_postcorrect.py +0 -0
  62. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_postprocess.py +0 -0
  63. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_prebuffer.py +0 -0
  64. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_prompt.py +0 -0
  65. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_recorder_integration.py +0 -0
  66. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_retention.py +0 -0
  67. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_robustness.py +0 -0
  68. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_security_hardening.py +0 -0
  69. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_server.py +0 -0
  70. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_setup.py +0 -0
  71. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_streaming.py +0 -0
  72. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_suggest.py +0 -0
  73. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_tokens.py +0 -0
  74. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_transcriber.py +0 -0
  75. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_tts.py +0 -0
  76. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_typer_manager.py +0 -0
  77. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_vad.py +0 -0
  78. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_vocabulary.py +0 -0
  79. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_wordfreq.py +0 -0
  80. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_worker_decode.py +0 -0
  81. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/__main__.py +0 -0
  82. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/audio_source.py +0 -0
  83. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/backends.py +0 -0
  84. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/__init__.py +0 -0
  85. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/configcmd.py +0 -0
  86. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/corrections.py +0 -0
  87. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/doctor.py +0 -0
  88. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/history.py +0 -0
  89. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/models.py +0 -0
  90. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/uninstall.py +0 -0
  91. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/vocab.py +0 -0
  92. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/clipboard_read.py +0 -0
  93. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/commands.py +0 -0
  94. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/config.py +0 -0
  95. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/consent.py +0 -0
  96. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/__init__.py +0 -0
  97. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/bias.py +0 -0
  98. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/__init__.py +0 -0
  99. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/base.py +0 -0
  100. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/catalog.py +0 -0
  101. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  102. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  103. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/lanes.py +0 -0
  104. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/sherpa.py +0 -0
  105. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/whispercpp.py +0 -0
  106. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/engine.py +0 -0
  107. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/pipeline.py +0 -0
  108. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/corrections.py +0 -0
  109. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  110. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/data/__init__.py +0 -0
  111. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/demo.py +0 -0
  112. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/display.py +0 -0
  113. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/feedback.py +0 -0
  114. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hints.py +0 -0
  115. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/history.py +0 -0
  116. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/__init__.py +0 -0
  117. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/base.py +0 -0
  118. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/chain.py +0 -0
  119. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/evdev.py +0 -0
  120. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  121. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/socket_backend.py +0 -0
  122. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/__init__.py +0 -0
  123. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/engine.py +0 -0
  124. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/install.py +0 -0
  125. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/pending.py +0 -0
  126. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/session.py +0 -0
  127. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/textlimit.py +0 -0
  128. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/__init__.py +0 -0
  129. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/augment.py +0 -0
  130. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/clean.py +0 -0
  131. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/importer.py +0 -0
  132. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/metrics.py +0 -0
  133. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/store.py +0 -0
  134. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/teacher.py +0 -0
  135. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/train.py +0 -0
  136. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/llm.py +0 -0
  137. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/llm_api.py +0 -0
  138. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/models/__init__.py +0 -0
  139. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/models/silero_vad.onnx +0 -0
  140. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/numbers.py +0 -0
  141. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/platform.py +0 -0
  142. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/postcorrect.py +0 -0
  143. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/postprocess.py +0 -0
  144. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/privacy.py +0 -0
  145. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/prompt.py +0 -0
  146. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/recorder.py +0 -0
  147. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/retention.py +0 -0
  148. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/__init__.py +0 -0
  149. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/__main__.py +0 -0
  150. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/app.py +0 -0
  151. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/config.py +0 -0
  152. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/runtime.py +0 -0
  153. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/__init__.py +0 -0
  154. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/configfile.py +0 -0
  155. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/extras.py +0 -0
  156. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/hotkey.py +0 -0
  157. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/noninteractive.py +0 -0
  158. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/speech.py +0 -0
  159. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/steps.py +0 -0
  160. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/system.py +0 -0
  161. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/ui.py +0 -0
  162. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/__init__.py +0 -0
  163. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/commit.wav +0 -0
  164. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/start.wav +0 -0
  165. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/stop.wav +0 -0
  166. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/streaming.py +0 -0
  167. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/suggest.py +0 -0
  168. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tokens.py +0 -0
  169. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/__init__.py +0 -0
  170. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_icons.py +0 -0
  171. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_indicator.py +0 -0
  172. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_pystray.py +0 -0
  173. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/__init__.py +0 -0
  174. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/base.py +0 -0
  175. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/chain.py +0 -0
  176. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/edge_engine.py +0 -0
  177. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/espeak.py +0 -0
  178. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/piper_engine.py +0 -0
  179. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/player.py +0 -0
  180. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/__init__.py +0 -0
  181. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/base.py +0 -0
  182. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/chain.py +0 -0
  183. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/clipboard.py +0 -0
  184. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/collecting.py +0 -0
  185. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/fcitx_bridge.py +0 -0
  186. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/ibus.py +0 -0
  187. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/manager.py +0 -0
  188. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/pynput_type.py +0 -0
  189. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/wtype.py +0 -0
  190. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/xdotool.py +0 -0
  191. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/ydotool.py +0 -0
  192. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ui.py +0 -0
  193. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vad.py +0 -0
  194. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vocab_stats.py +0 -0
  195. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vocabulary.py +0 -0
  196. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/watchdog.py +0 -0
  197. {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -182,7 +182,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
182
182
  voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
183
183
  voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
184
184
  voiceio learn review # fix the teacher where it matters: your names and terms
185
- voiceio learn review --test # once: verify the held-out clips, so eval scores against the truth
185
+ voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
186
+ # where the teacher and your live decode disagree, quickest clips first
186
187
  voiceio learn augment # synthesize extra examples of rare terms with local TTS
187
188
  voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
188
189
  voiceio learn eval # score the current model on your held-out clips: WER and term recall
@@ -191,6 +192,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
191
192
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
192
193
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
193
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
196
  ```
195
197
 
196
198
  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`.
@@ -301,7 +303,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
301
303
  ## Roadmap
302
304
 
303
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.
304
- - **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
305
306
  - **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)).
306
307
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
307
308
 
@@ -129,7 +129,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
129
129
  voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
130
130
  voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
131
131
  voiceio learn review # fix the teacher where it matters: your names and terms
132
- voiceio learn review --test # once: verify the held-out clips, so eval scores against the truth
132
+ voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
133
+ # where the teacher and your live decode disagree, quickest clips first
133
134
  voiceio learn augment # synthesize extra examples of rare terms with local TTS
134
135
  voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
135
136
  voiceio learn eval # score the current model on your held-out clips: WER and term recall
@@ -138,6 +139,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
138
139
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
139
140
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
140
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
143
  ```
142
144
 
143
145
  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`.
@@ -248,7 +250,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
248
250
  ## Roadmap
249
251
 
250
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.
251
- - **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
252
253
  - **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)).
253
254
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
254
255
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -182,7 +182,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
182
182
  voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
183
183
  voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
184
184
  voiceio learn review # fix the teacher where it matters: your names and terms
185
- voiceio learn review --test # once: verify the held-out clips, so eval scores against the truth
185
+ voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
186
+ # where the teacher and your live decode disagree, quickest clips first
186
187
  voiceio learn augment # synthesize extra examples of rare terms with local TTS
187
188
  voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
188
189
  voiceio learn eval # score the current model on your held-out clips: WER and term recall
@@ -191,6 +192,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
191
192
  voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
192
193
  voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
193
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
196
  ```
195
197
 
196
198
  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`.
@@ -301,7 +303,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
301
303
  ## Roadmap
302
304
 
303
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.
304
- - **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
305
306
  - **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)).
306
307
  - **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
307
308
 
@@ -132,14 +132,34 @@ def test_learn_jobs_reexec_into_an_idle_scope(monkeypatch):
132
132
 
133
133
 
134
134
  def test_promotion_requires_a_winning_eval():
135
- base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0}
136
- better = {"wer": 0.101, "term_recall": 0.70, "repetitions": 0} # vocab win, WER within slack
137
- worse = {"wer": 0.13, "term_recall": 0.70, "repetitions": 0}
135
+ base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0, "clips": 44}
136
+ better = {"wer": 0.101, "term_recall": 0.70, "repetitions": 0, "clips": 44} # vocab win
137
+ worse = {"wer": 0.13, "term_recall": 0.70, "repetitions": 0, "clips": 44}
138
138
  assert evaluate.promotion_verdict(better, base)[0]
139
139
  assert not evaluate.promotion_verdict(worse, base)[0]
140
140
  assert not evaluate.promotion_verdict({**better, "repetitions": 2}, base)[0]
141
141
 
142
142
 
143
+ def test_no_promotion_without_evidence():
144
+ """A crashed decode scores 0% WER; an empty test split scores nothing."""
145
+ base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0, "clips": 44}
146
+ perfect = {"wer": 0.0, "term_recall": 1.0, "repetitions": 0}
147
+ assert not evaluate.promotion_verdict({**perfect, "clips": 44, "failures": 3}, base)[0]
148
+ assert not evaluate.promotion_verdict({**perfect, "clips": 40}, base)[0] # other clips
149
+ assert not evaluate.promotion_verdict({**perfect, "clips": 2}, {**base, "clips": 2})[0]
150
+
151
+
152
+ def test_latest_finds_runs_at_the_configured_beam():
153
+ runs = store.learn_dir() / "runs"
154
+ runs.mkdir(parents=True)
155
+ for i, (dec, wer) in enumerate([("faster-whisper:small", 5), ("faster-whisper:small@beam2+draft", 2)]):
156
+ (runs / f"eval-2026010{i}.json").write_text(json.dumps(
157
+ {"decoder": dec, "dataset": "v0001", "wer": wer, "metrics": metrics.METRICS_VERSION}))
158
+ assert evaluate.latest("small", "v0001")["wer"] == 5
159
+ assert evaluate.latest("small", "v0001", beam=2)["wer"] == 2
160
+ assert evaluate.latest("small", "v0001", beam=1) is None
161
+
162
+
143
163
  def test_latest_eval_matches_model_dataset_and_mode():
144
164
  runs = store.learn_dir() / "runs"
145
165
  runs.mkdir(parents=True)
@@ -308,3 +328,41 @@ def test_wait_summary_p90_never_exceeds_max():
308
328
  s = r.wait_summary()
309
329
  p90 = float(s.split("p90 ")[1].split("s")[0])
310
330
  assert p90 <= 6.83
331
+
332
+
333
+ def test_maintain_auto_promote_switches_restarts_and_says_how_to_undo(monkeypatch):
334
+ import argparse
335
+ from types import SimpleNamespace
336
+
337
+ from voiceio import feedback, service
338
+ from voiceio.cli import learn as cli
339
+ from voiceio.learn import evaluate
340
+ for i in range(3):
341
+ _clip(f"{i}.wav", 3, "x")
342
+ store.add_label(f"{i}.wav", [Segment(0, 3, "hello there")], "teacher:fake")
343
+ run = store.learn_dir() / "models" / "v0001-small-x"
344
+ calls = []
345
+ monkeypatch.setattr(cli, "cmd_label", lambda a: None)
346
+ monkeypatch.setattr(train, "missing_train_deps", lambda: [])
347
+ monkeypatch.setattr(cli, "_run_eval", lambda *a, **k: SimpleNamespace(
348
+ wer=0.1, term_recall=0.5, repetitions=0, per_clip=[]))
349
+ monkeypatch.setattr(cli, "_train", lambda *a, **k: run)
350
+ monkeypatch.setattr(evaluate, "promotion_verdict", lambda c, b: (True, "table"))
351
+ monkeypatch.setattr(cli, "cmd_promote", lambda a: calls.append(("promote", a.run)))
352
+ monkeypatch.setattr(service, "is_installed", lambda: True)
353
+ monkeypatch.setattr(service, "restart_when_idle", lambda: calls.append(("restart",)) or True)
354
+ monkeypatch.setattr(feedback, "notify", lambda t, b, urgent=False: calls.append(("notify", b)))
355
+
356
+ cli.cmd_maintain(argparse.Namespace(min_new=1, base="small", epochs=1, auto_promote=True))
357
+ assert calls[:2] == [("promote", "v0001-small-x"), ("restart",)]
358
+ assert "voiceio learn rollback" in calls[2][1]
359
+
360
+ # The gate's own re-check refuses: no restart, no "switched" notification,
361
+ # and no crash (the nightly unit must not fail).
362
+ calls.clear()
363
+
364
+ def refuse(a):
365
+ raise SystemExit("evaluate both models first")
366
+ monkeypatch.setattr(cli, "cmd_promote", refuse)
367
+ cli.cmd_maintain(argparse.Namespace(min_new=1, base="small", epochs=1, auto_promote=True))
368
+ assert calls == []
@@ -0,0 +1,44 @@
1
+
2
+
3
+ def test_queue_all_covers_every_segment_of_unreviewed_clips():
4
+ from voiceio.learn import review
5
+ from voiceio.learn.store import Clip, Label, Segment
6
+ a = Clip("a.wav", 0, 4, "", "")
7
+ b = Clip("b.wav", 0, 4, "", "")
8
+ segs = (Segment(0, 2, "one"), Segment(2, 4, "two"))
9
+ labels = {"a.wav": Label("a.wav", segs, "teacher:x", 0),
10
+ "b.wav": Label("b.wav", segs, "human", 0)}
11
+ items = review.queue_all([a, b], labels)
12
+ assert [(i.clip.audio, i.index) for i in items] == [("a.wav", 0), ("a.wav", 1)]
13
+
14
+
15
+ def test_live_span_shows_the_live_decode_only_where_it_differs():
16
+ from voiceio.learn.review import live_span
17
+ live = "okay so we use kalchi daily and then more stuff"
18
+ assert live_span("We use Kalshi daily.", live) == "we use kalchi daily"
19
+ assert live_span("We use Kalshi daily.", "so we use Kalshi daily ok") is None
20
+ assert live_span("totally different words here", "nothing alike at all") is None
21
+
22
+
23
+ def test_gold_review_asks_only_where_the_models_disagree_cheapest_clip_first():
24
+ from voiceio.learn import review
25
+ from voiceio.learn.store import Clip, Label, Segment
26
+ segs = (Segment(0, 2, "we use Kalshi daily"), Segment(2, 4, "ship it today"))
27
+ two = Clip("two.wav", 0, 4, "we use kalchi daily ship it to day", "")
28
+ one = Clip("one.wav", 0, 4, "we use kalchi daily ship it today", "")
29
+ agree = Clip("agree.wav", 0, 4, "we use Kalshi daily ship it today", "")
30
+ labels = {c.audio: Label(c.audio, segs, "teacher:x", 0) for c in (two, one, agree)}
31
+ items = review.queue_all([two, one, agree], labels)
32
+ assert [(i.clip.audio, i.index) for i in items] == [
33
+ ("one.wav", 0), # one answer completes a gold clip
34
+ ("two.wav", 0), ("two.wav", 1),
35
+ ] # agree.wav: nothing to ask
36
+
37
+
38
+ def test_gold_review_counts_one_misheard_term_as_disagreement():
39
+ from voiceio.learn import review
40
+ from voiceio.learn.store import Clip, Label, Segment
41
+ segs = (Segment(0, 4, "we ship the Kalshi bot every single day"),)
42
+ clip = Clip("a.wav", 0, 4, "we ship the kalchi bot every single day", "")
43
+ items = review.queue_all([clip], {"a.wav": Label("a.wav", segs, "teacher:x", 0)})
44
+ assert len(items) == 1
@@ -48,15 +48,46 @@ def test_learn_timer_runs_maintain_at_idle_priority(monkeypatch, tmp_path):
48
48
  def test_daemon_publishes_state_and_restart_waits_for_idle(monkeypatch):
49
49
  import threading
50
50
 
51
- from voiceio import config
51
+ from voiceio import config, pidlock
52
52
  from voiceio.app import VoiceIO, _State
53
53
  from voiceio.cli.servicecmd import wait_until_idle
54
54
 
55
- assert wait_until_idle(timeout=1) # not running: no wait
56
55
  vio = VoiceIO.__new__(VoiceIO)
56
+ vio._state = _State.RECORDING # not the lock holder:
57
+ assert not config.STATE_PATH.exists() # says nothing
58
+ vio._publishes_state = True
57
59
  vio._state = _State.RECORDING
58
60
  assert config.STATE_PATH.read_text() == "recording"
61
+
62
+ monkeypatch.setattr(pidlock, "daemon_alive", lambda path: False)
63
+ assert wait_until_idle(timeout=5) # dead daemon: stale file, no wait
64
+ monkeypatch.setattr(pidlock, "daemon_alive", lambda path: True)
59
65
  threading.Timer(0.3, lambda: setattr(vio, "_state", _State.IDLE)).start()
60
- assert wait_until_idle(timeout=5, poll=0.05) # waited for the dictation
61
- vio._state = _State.FINALIZING
62
- assert not wait_until_idle(timeout=0.2, poll=0.05) # gives up, says so
66
+ assert wait_until_idle(timeout=5, poll=0.05) # waited for the dictation
67
+ vio._state = _State.FINALIZING # a hung finalize: short budget
68
+ assert not wait_until_idle(timeout=60, poll=0.05, finalizing_timeout=0.2)
69
+
70
+
71
+ def test_daemon_alive_probes_the_pid_lock(tmp_path):
72
+ import fcntl
73
+
74
+ from voiceio.pidlock import daemon_alive
75
+ path = tmp_path / "voiceio.pid"
76
+ assert not daemon_alive(path)
77
+ with open(path, "a") as held:
78
+ fcntl.flock(held, fcntl.LOCK_EX)
79
+ assert daemon_alive(path)
80
+ assert not daemon_alive(path)
81
+
82
+
83
+ def test_learn_timer_auto_promote_is_opt_in(monkeypatch, tmp_path):
84
+ monkeypatch.setattr(service, "has_systemd", lambda: True)
85
+ monkeypatch.setattr(service, "_systemctl", lambda *a: True)
86
+ monkeypatch.setattr(service, "_find_voiceio_bin", lambda: "voiceio")
87
+ monkeypatch.setattr(service, "LEARN_SERVICE_PATH", tmp_path / "s.service")
88
+ monkeypatch.setattr(service, "LEARN_TIMER_PATH", tmp_path / "s.timer")
89
+ service.install_learn_timer()
90
+ assert not service.learn_timer_auto_promotes()
91
+ service.install_learn_timer(auto_promote=True)
92
+ assert "learn maintain --auto-promote" in service.LEARN_SERVICE_PATH.read_text()
93
+ assert service.learn_timer_auto_promotes()
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -43,6 +43,10 @@ class VoiceIO:
43
43
  @_state.setter
44
44
  def _state(self, value: _State) -> None:
45
45
  self.__state = value
46
+ # Only the instance holding the PID lock speaks for the daemon: a
47
+ # second launch must not overwrite the running one's state.
48
+ if not getattr(self, "_publishes_state", False):
49
+ return
46
50
  try: # best-effort: only `service restart` reads it
47
51
  tmp = config.STATE_PATH.with_suffix(".tmp")
48
52
  tmp.write_text(value.value)
@@ -660,7 +664,7 @@ class VoiceIO:
660
664
  self._typers.redetect_at_start()
661
665
 
662
666
  LOG_DIR.mkdir(parents=True, exist_ok=True)
663
- self._pid_fd = open(PID_PATH, "w")
667
+ self._pid_fd = open(PID_PATH, "a") # not "w": the running daemon's PID stays
664
668
  try:
665
669
  from voiceio.pidlock import lock_pid_file
666
670
  lock_pid_file(self._pid_fd)
@@ -669,8 +673,11 @@ class VoiceIO:
669
673
  log.error("Another voiceio instance is already running")
670
674
  print("voiceio is already running. Stop it first: voiceio service stop")
671
675
  return
676
+ self._pid_fd.truncate(0)
672
677
  self._pid_fd.write(str(os.getpid()))
673
678
  self._pid_fd.flush()
679
+ self._publishes_state = True
680
+ self._state = self._state
674
681
 
675
682
  self._typers.prepare()
676
683
 
@@ -84,10 +84,14 @@ def register(sub: argparse._SubParsersAction) -> None:
84
84
  help="new labeled clips needed before retraining (default 50)")
85
85
  mt.add_argument("--base", default="small")
86
86
  mt.add_argument("--epochs", type=int, default=4)
87
+ mt.add_argument("--auto-promote", action="store_true",
88
+ help="switch to the fine-tune when it wins, and restart the daemon")
87
89
  mt.set_defaults(func=cmd_maintain)
88
90
 
89
91
  sc = lsub.add_parser("schedule", help="Run `maintain` every night (systemd timer)")
90
92
  sc.add_argument("state", nargs="?", choices=("on", "off"), help="omit to show")
93
+ sc.add_argument("--auto-promote", action="store_true",
94
+ help="with on: switch to a fine-tune that wins, without asking")
91
95
  sc.set_defaults(func=cmd_schedule)
92
96
 
93
97
  pr = lsub.add_parser("promote", help="Use a trained model for dictation")
@@ -182,8 +186,7 @@ def cmd_status(args: argparse.Namespace) -> None:
182
186
  print(f"models {', '.join(runs)}")
183
187
  active = _cfg().model.name
184
188
  print(f"active {active}")
185
- from voiceio import service
186
- print(f"nightly {'on' if service.learn_timer_enabled() else 'off (voiceio learn schedule on)'}")
189
+ print(f"nightly {_schedule_state()}")
187
190
  promotable = _print_scores(ds, active, runs) if ds else None
188
191
  if promotable:
189
192
  nxt = f"promote {promotable}"
@@ -197,9 +200,9 @@ def _print_scores(ds: Path, active: str, runs: list[str]) -> str | None:
197
200
  """Scores on `ds` for the active model and each fine-tune; returns the
198
201
  run that beats the active model on everything, if any."""
199
202
  from voiceio.learn import evaluate
200
- current = evaluate.latest(active, ds.name)
203
+ current = evaluate.latest(active, ds.name, beam=_cfg().model.beam_size)
201
204
  rows = [(active + " (active)", current)] + [
202
- (run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name)) for run in runs]
205
+ (run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name, beam=_cfg().model.beam_size)) for run in runs]
203
206
  if not any(r for _, r in rows):
204
207
  return None
205
208
  print(f"\nscores on {ds.name} test clips:")
@@ -442,8 +445,9 @@ def cmd_maintain(args: argparse.Namespace) -> None:
442
445
  labeled = {c.audio for c in store.clips() if c.audio in labels}
443
446
  ds = store.latest_dataset()
444
447
  new = len(labeled - store.dataset_clips(ds)) if ds else len(labeled)
445
- if ds is not None and new < args.min_new:
446
- print(f"{new} new labeled clips since {ds.name}; retraining waits for {args.min_new}.")
448
+ if new < args.min_new: # the first dataset too: a handful of clips trains nothing
449
+ since = f" since {ds.name}" if ds else ""
450
+ print(f"{new} new labeled clips{since}; retraining waits for {args.min_new}.")
447
451
  return
448
452
  ds = store.build_dataset()
449
453
  print(f"built {ds.name} ({new} new clips)")
@@ -454,13 +458,39 @@ def cmd_maintain(args: argparse.Namespace) -> None:
454
458
  print(f"\n{_cfg().model.name} → {run.name} on {ds.name}:\n{table}")
455
459
  print(f"better or equal on everything — `voiceio learn promote {run.name}`" if ok
456
460
  else "not better on everything — keeping the current model")
457
- if ok: # often runs unattended at night: say so where it will be seen
458
- from voiceio import feedback
461
+ if not ok:
462
+ return
463
+ # Often runs unattended at night: say so where it will be seen.
464
+ from voiceio import feedback
465
+ if getattr(args, "auto_promote", False):
466
+ try:
467
+ cmd_promote(argparse.Namespace(run=run.name, force=False))
468
+ except SystemExit as refused: # the gate's own re-check said no
469
+ print(f"not promoted: {refused}")
470
+ return
471
+ from voiceio import service
472
+ if service.is_installed() and not service.restart_when_idle():
473
+ feedback.notify("VoiceIO: a better model is ready",
474
+ f"Switched to {run.name}; restart voiceio to use it "
475
+ "(a dictation was in progress). Undo: voiceio learn rollback",
476
+ urgent=True)
477
+ return
478
+ feedback.notify("VoiceIO: switched to a better model",
479
+ f"Trained on your dictation: {run.name}. Undo: voiceio learn rollback",
480
+ urgent=True)
481
+ else:
459
482
  feedback.notify("VoiceIO: a better model is ready",
460
483
  f"Trained on your dictation. Use it: voiceio learn promote {run.name}",
461
484
  urgent=True)
462
485
 
463
486
 
487
+ def _schedule_state() -> str:
488
+ 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"
492
+
493
+
464
494
  def cmd_schedule(args: argparse.Namespace) -> None:
465
495
  from voiceio import config, service
466
496
  if args.state == "on":
@@ -471,16 +501,18 @@ def cmd_schedule(args: argparse.Namespace) -> None:
471
501
  if missing := train.missing_train_deps():
472
502
  print(f"note: until {', '.join(missing)} are installed it only labels new clips;\n"
473
503
  f" to also retrain: {train.TRAIN_HINT}")
474
- if not service.install_learn_timer():
504
+ if not service.install_learn_timer(auto_promote=args.auto_promote):
475
505
  raise SystemExit("could not enable the timer (needs systemd --user)")
476
506
  print("on: every night ~03:30, `voiceio learn maintain` labels new dictation at idle "
477
- "priority, retrains once 50+ clips are new, and tells you if the result is better. "
478
- "It never switches models by itself.")
507
+ "priority, retrains once 50+ clips are new, and "
508
+ + ("switches to the result when it wins on your held-out clips "
509
+ "(undo: voiceio learn rollback)." if args.auto_promote else
510
+ "tells you if the result is better. It never switches models by itself."))
479
511
  elif args.state == "off":
480
512
  service.uninstall_learn_timer()
481
513
  print("off")
482
514
  else:
483
- print("on" if service.learn_timer_enabled() else "off — enable: voiceio learn schedule on")
515
+ print(_schedule_state())
484
516
 
485
517
 
486
518
  def _scores(result) -> dict:
@@ -506,16 +538,16 @@ def _check_beats_current(ct2: Path) -> None:
506
538
  """Refuse a promotion the evals don't support (a fine-tune can regress)."""
507
539
  from voiceio.learn import evaluate
508
540
  ds = store.latest_dataset()
509
- current = _cfg().model.name
541
+ current, beam = _cfg().model.name, _cfg().model.beam_size
510
542
  if ds is None:
511
543
  raise SystemExit("no dataset to compare on — `voiceio learn build`, or --force")
512
- missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name) is None]
544
+ missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name, beam=beam) is None]
513
545
  if missing:
514
546
  raise SystemExit("evaluate both models on the same clips first:\n"
515
547
  + "".join(f" voiceio learn eval --model {m}\n" for m in missing)
516
548
  + "(or promote anyway with --force)")
517
- ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name),
518
- evaluate.latest(current, ds.name))
549
+ ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name, beam=beam),
550
+ evaluate.latest(current, ds.name, beam=beam))
519
551
  print(f"on {ds.name} test clips, {current} → fine-tune:\n{table}")
520
552
  if not ok:
521
553
  raise SystemExit("the fine-tune is worse on something — not promoting (--force overrides)")
@@ -85,23 +85,35 @@ def _cmd_service(args: argparse.Namespace) -> None:
85
85
  pass
86
86
 
87
87
 
88
- def wait_until_idle(timeout: float = 300.0, poll: float = 0.25) -> bool:
88
+ def wait_until_idle(timeout: float = 300.0, poll: float = 0.25,
89
+ finalizing_timeout: float = 60.0) -> bool:
89
90
  """Wait for the daemon to finish any dictation in progress (stopping it
90
- mid-recording loses what was said). True once idle or not running."""
91
+ mid-recording loses what was said). True once idle or not running; False
92
+ when it stayed busy. A decode stuck finalizing gets a shorter wait: a
93
+ restart is how that is recovered."""
91
94
  import time
92
95
 
93
- from voiceio.config import STATE_PATH
94
- deadline, told = time.monotonic() + timeout, False
95
- while time.monotonic() < deadline:
96
- try:
97
- state = STATE_PATH.read_text().strip()
98
- except OSError:
99
- return True # not running (or pre-1.1): nothing to wait for
100
- if state in ("idle", "error"):
101
- return True
102
- if not told:
103
- print(f"Waiting for the dictation in progress to finish ({state})…", flush=True)
104
- told = True
105
- time.sleep(poll)
106
- print("Still busy; going ahead. (--now skips the wait.)", file=sys.stderr)
107
- return False
96
+ from voiceio.config import PID_PATH, STATE_PATH
97
+ from voiceio.pidlock import daemon_alive
98
+ start, told = time.monotonic(), False
99
+ try:
100
+ while True:
101
+ if not daemon_alive(PID_PATH):
102
+ return True # crashed or stopped: its state file is stale
103
+ try:
104
+ state = STATE_PATH.read_text().strip()
105
+ except OSError:
106
+ return True # pre-1.1 daemon: nothing to wait for
107
+ if state in ("idle", "error"):
108
+ return True
109
+ waited = time.monotonic() - start
110
+ if waited > (finalizing_timeout if state == "finalizing" else timeout):
111
+ print(f"Still {state}; going ahead. (--now skips the wait.)", file=sys.stderr)
112
+ return False
113
+ if not told:
114
+ print(f"Waiting for the dictation in progress to finish ({state})…", flush=True)
115
+ told = True
116
+ time.sleep(poll)
117
+ except KeyboardInterrupt:
118
+ print("\nNot waiting.", file=sys.stderr)
119
+ sys.exit(130)
@@ -247,7 +247,8 @@ 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"] = "on" if service.learn_timer_enabled() else "off"
250
+ out["Nightly learning"] = ("off" if not service.learn_timer_enabled() else
251
+ "on (auto-promote)" if service.learn_timer_auto_promotes() else "on")
251
252
  return out
252
253
 
253
254
 
@@ -156,11 +156,11 @@ def save(result: EvalResult) -> Path:
156
156
  return path
157
157
 
158
158
 
159
- def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
160
- """The newest saved run of `model` (a faster-whisper name or path) on
161
- `dataset` scored by the current metrics, or None."""
159
+ def latest(model: str, dataset: str, mode: str = "offline", beam: int = 5) -> dict | None:
160
+ """The newest saved run of `model` (a faster-whisper name or path) at
161
+ beam width `beam` on `dataset` scored by the current metrics, or None."""
162
162
  runs = learn_dir() / "runs"
163
- want = f"faster-whisper:{model}"
163
+ want = f"faster-whisper:{model}" + (f"@beam{beam}" if beam != 5 else "")
164
164
  for path in sorted(runs.glob("eval-*.json"), reverse=True) if runs.exists() else []:
165
165
  try:
166
166
  data = json.loads(path.read_text())
@@ -173,6 +173,9 @@ def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
173
173
  return None
174
174
 
175
175
 
176
+ # Fewer held-out clips than this can't tell a better model from luck.
177
+ MIN_EVAL_CLIPS = 5
178
+
176
179
  # A fine-tune may trade a hair of WER for vocabulary; it may not regress either.
177
180
  WER_SLACK = 0.002
178
181
 
@@ -196,7 +199,10 @@ def paired(candidate: dict, current: dict, *, n_boot: int = 2000) -> dict | None
196
199
 
197
200
 
198
201
  def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
199
- """Is `candidate` at least as good as `current` on the same clips?"""
202
+ """Is `candidate` at least as good as `current` on the same clips?
203
+
204
+ No verdict without evidence: too few clips, different clips, or a clip
205
+ the candidate failed to decode at all (a crashed decode scores 0% WER)."""
200
206
  rows = [("WER", current["wer"], candidate["wer"], "{:.1%}"),
201
207
  ("vocab recall", current["term_recall"], candidate["term_recall"], "{:.1%}"),
202
208
  ("loops", current["repetitions"], candidate["repetitions"], "{}")]
@@ -208,6 +214,10 @@ def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
208
214
  f"WER change {p['mean']:+.1%} (90% interval {p['lo']:+.1%} … {p['hi']:+.1%})")
209
215
  if p["lo"] < 0 < p["hi"]:
210
216
  table += "\n (the interval spans zero: the WER difference may be luck)"
217
+ clips = candidate.get("clips", 0)
218
+ if candidate.get("failures", 0) or clips != current.get("clips", 0) or clips < MIN_EVAL_CLIPS:
219
+ return False, table + (f"\n not enough evidence: {clips} clips "
220
+ f"({candidate.get('failures', 0)} failed) vs {current.get('clips', 0)}")
211
221
  ok = (candidate["wer"] <= current["wer"] + WER_SLACK
212
222
  and candidate["term_recall"] >= current["term_recall"]
213
223
  and candidate["repetitions"] <= current["repetitions"])
@@ -26,8 +26,10 @@ class Item:
26
26
  reason: str
27
27
 
28
28
 
29
- def _contains(seg_words: list[str], live_words: list[str]) -> bool:
30
- """Whether the segment's words occur (nearly) contiguously in the live text."""
29
+ def _contains(seg_words: list[str], live_words: list[str],
30
+ tolerance: float = _DISAGREE_WER) -> bool:
31
+ """Whether the segment's words occur (nearly, within `tolerance` of its
32
+ words) contiguously in the live text."""
31
33
  n = len(seg_words)
32
34
  if not n:
33
35
  return True
@@ -36,7 +38,7 @@ def _contains(seg_words: list[str], live_words: list[str]) -> bool:
36
38
  window = live_words[i:i + n]
37
39
  match = sum(a == b for a, b in zip(seg_words, window)) / n
38
40
  best = max(best, match)
39
- if best >= 1 - _DISAGREE_WER:
41
+ if best >= 1 - tolerance:
40
42
  return True
41
43
  return False
42
44
 
@@ -84,12 +86,31 @@ def live_span(segment_text: str, live: str) -> str | None:
84
86
 
85
87
 
86
88
  def queue_all(clips: list[Clip], labels: dict[str, Label]) -> list[Item]:
87
- """Every segment of every unreviewed clip — for building a gold test set,
88
- where a reference is only trustworthy if all of it was checked."""
89
- return [Item(clip, labels[clip.audio], i, "test set")
90
- for clip in clips
91
- if clip.audio in labels and not labels[clip.audio].reviewed
92
- for i in range(len(labels[clip.audio].segments))]
89
+ """Segments to check for a gold test set, cheapest clips first.
90
+
91
+ A reference is only trustworthy if all of it was checked — but where the
92
+ teacher and the live decode agree word for word, two independent models
93
+ already did; those segments are kept as they are, and only the rest are
94
+ asked. Clips needing the fewest answers come first, so each minute of
95
+ review completes as many gold clips as possible. A clip with no live
96
+ decode (an import) has every segment asked; one where everything agrees
97
+ asks nothing and stays a teacher label.
98
+ """
99
+ per_clip: list[list[Item]] = []
100
+ for clip in clips:
101
+ lab = labels.get(clip.audio)
102
+ if lab is None or lab.reviewed:
103
+ continue
104
+ live = metrics.words(clip.raw)
105
+ items = [Item(clip, lab, i, "test set: teacher and live disagree" if live else "test set")
106
+ for i, seg in enumerate(lab.segments)
107
+ # Word for word: a gold reference can't skip "kalchi" vs
108
+ # "Kalshi" in an otherwise matching sentence.
109
+ if not live or not _contains(metrics.words(seg.text), live, tolerance=0.0)]
110
+ if items:
111
+ per_clip.append(items)
112
+ per_clip.sort(key=len)
113
+ return [it for items in per_clip for it in items]
93
114
 
94
115
 
95
116
  def run(items: list[Item], *, ask: Callable[[Item], str | None],