python-voiceio 1.16.1__tar.gz → 1.17.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 (236) hide show
  1. {python_voiceio-1.16.1/python_voiceio.egg-info → python_voiceio-1.17.0}/PKG-INFO +2 -2
  2. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/README.md +1 -1
  3. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/pyproject.toml +1 -1
  4. {python_voiceio-1.16.1 → python_voiceio-1.17.0/python_voiceio.egg-info}/PKG-INFO +2 -2
  5. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/python_voiceio.egg-info/SOURCES.txt +2 -0
  6. python_voiceio-1.17.0/tests/test_errorlog.py +136 -0
  7. python_voiceio-1.17.0/voiceio/__init__.py +1 -0
  8. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/__init__.py +4 -1
  9. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/doctor.py +21 -6
  10. python_voiceio-1.17.0/voiceio/errorlog.py +177 -0
  11. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/report.py +5 -0
  12. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tray/__init__.py +1 -0
  13. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tray/_indicator.py +4 -0
  14. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tray/_pystray.py +1 -0
  15. python_voiceio-1.16.1/voiceio/__init__.py +0 -1
  16. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/LICENSE +0 -0
  17. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  18. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  19. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/python_voiceio.egg-info/requires.txt +0 -0
  20. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/python_voiceio.egg-info/top_level.txt +0 -0
  21. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/setup.cfg +0 -0
  22. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_announce.py +0 -0
  23. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_app_wiring.py +0 -0
  24. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_audio_quality.py +0 -0
  25. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_audio_source.py +0 -0
  26. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_backend_probes.py +0 -0
  27. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_cancel.py +0 -0
  28. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_cli.py +0 -0
  29. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_clipboard_read.py +0 -0
  30. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_clipboard_typer.py +0 -0
  31. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_commands.py +0 -0
  32. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_concurrency_lockdown.py +0 -0
  33. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_config.py +0 -0
  34. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_context_terms.py +0 -0
  35. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_corrections.py +0 -0
  36. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_dead_mic.py +0 -0
  37. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_decoders.py +0 -0
  38. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_engine.py +0 -0
  39. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_evdev_uaccess.py +0 -0
  40. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_fallback.py +0 -0
  41. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_fcitx_bridge.py +0 -0
  42. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_feedback_stream.py +0 -0
  43. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_focus.py +0 -0
  44. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_gpu_fallback.py +0 -0
  45. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_health.py +0 -0
  46. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_hints.py +0 -0
  47. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_history.py +0 -0
  48. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_hotkey_modes.py +0 -0
  49. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_engine_limits.py +0 -0
  50. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_engine_listener.py +0 -0
  51. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_pending.py +0 -0
  52. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_pids.py +0 -0
  53. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_ping.py +0 -0
  54. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_session.py +0 -0
  55. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_textlimit.py +0 -0
  56. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_ibus_typer.py +0 -0
  57. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_idle_audio.py +0 -0
  58. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_clean.py +0 -0
  59. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_import.py +0 -0
  60. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_language.py +0 -0
  61. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_metrics.py +0 -0
  62. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_pipeline.py +0 -0
  63. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_quiet.py +0 -0
  64. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_resume.py +0 -0
  65. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_review.py +0 -0
  66. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_learn_targets.py +0 -0
  67. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_llm.py +0 -0
  68. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_llm_api.py +0 -0
  69. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_mic_gain.py +0 -0
  70. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_modifier_guard.py +0 -0
  71. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_numbers.py +0 -0
  72. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_paste_last.py +0 -0
  73. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_pipeline.py +0 -0
  74. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_platform.py +0 -0
  75. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_postcorrect.py +0 -0
  76. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_postprocess.py +0 -0
  77. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_prebuffer.py +0 -0
  78. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_presence.py +0 -0
  79. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_prompt.py +0 -0
  80. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_quiet_audio.py +0 -0
  81. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_recorder_integration.py +0 -0
  82. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_report.py +0 -0
  83. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_retention.py +0 -0
  84. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_robustness.py +0 -0
  85. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_security_hardening.py +0 -0
  86. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_server.py +0 -0
  87. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_service.py +0 -0
  88. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_setup.py +0 -0
  89. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_sherpa.py +0 -0
  90. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_spool.py +0 -0
  91. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_streaming.py +0 -0
  92. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_suggest.py +0 -0
  93. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_tokens.py +0 -0
  94. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_transcriber.py +0 -0
  95. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_tray.py +0 -0
  96. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_tts.py +0 -0
  97. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_typer_manager.py +0 -0
  98. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_vad.py +0 -0
  99. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_vocabulary.py +0 -0
  100. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_wordfreq.py +0 -0
  101. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/tests/test_worker_decode.py +0 -0
  102. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/__main__.py +0 -0
  103. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/announce.py +0 -0
  104. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/app.py +0 -0
  105. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/audio_source.py +0 -0
  106. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/backends.py +0 -0
  107. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/configcmd.py +0 -0
  108. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/corrections.py +0 -0
  109. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/history.py +0 -0
  110. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/learn.py +0 -0
  111. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/models.py +0 -0
  112. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/servicecmd.py +0 -0
  113. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/uninstall.py +0 -0
  114. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/cli/vocab.py +0 -0
  115. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/clipboard_read.py +0 -0
  116. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/commands.py +0 -0
  117. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/config.py +0 -0
  118. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/consent.py +0 -0
  119. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/context_terms.py +0 -0
  120. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/__init__.py +0 -0
  121. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/bias.py +0 -0
  122. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/__init__.py +0 -0
  123. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/base.py +0 -0
  124. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/catalog.py +0 -0
  125. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
  126. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
  127. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/fetch.py +0 -0
  128. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/lanes.py +0 -0
  129. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/sherpa.py +0 -0
  130. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/decoders/whispercpp.py +0 -0
  131. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/engine.py +0 -0
  132. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/core/pipeline.py +0 -0
  133. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/corrections.py +0 -0
  134. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
  135. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/data/__init__.py +0 -0
  136. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/demo.py +0 -0
  137. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/display.py +0 -0
  138. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/feedback.py +0 -0
  139. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/focus.py +0 -0
  140. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/health.py +0 -0
  141. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hints.py +0 -0
  142. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/history.py +0 -0
  143. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/__init__.py +0 -0
  144. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/base.py +0 -0
  145. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/chain.py +0 -0
  146. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/evdev.py +0 -0
  147. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  148. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/hotkeys/socket_backend.py +0 -0
  149. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/__init__.py +0 -0
  150. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/engine.py +0 -0
  151. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/install.py +0 -0
  152. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/pending.py +0 -0
  153. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/session.py +0 -0
  154. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ibus/textlimit.py +0 -0
  155. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/idle_audio.py +0 -0
  156. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/__init__.py +0 -0
  157. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/augment.py +0 -0
  158. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/clean.py +0 -0
  159. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/evaluate.py +0 -0
  160. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/importer.py +0 -0
  161. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/metrics.py +0 -0
  162. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/quiet.py +0 -0
  163. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/review.py +0 -0
  164. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/store.py +0 -0
  165. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/teacher.py +0 -0
  166. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/learn/train.py +0 -0
  167. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/llm.py +0 -0
  168. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/llm_api.py +0 -0
  169. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/mic_gain.py +0 -0
  170. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/models/__init__.py +0 -0
  171. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/models/silero_vad.onnx +0 -0
  172. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/numbers.py +0 -0
  173. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/pidlock.py +0 -0
  174. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/platform.py +0 -0
  175. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/postcorrect.py +0 -0
  176. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/postprocess.py +0 -0
  177. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/power.py +0 -0
  178. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/presence.py +0 -0
  179. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/privacy.py +0 -0
  180. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/prompt.py +0 -0
  181. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/quiet_audio.py +0 -0
  182. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/recorder.py +0 -0
  183. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/retention.py +0 -0
  184. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/server/__init__.py +0 -0
  185. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/server/__main__.py +0 -0
  186. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/server/app.py +0 -0
  187. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/server/config.py +0 -0
  188. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/server/runtime.py +0 -0
  189. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/service.py +0 -0
  190. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/__init__.py +0 -0
  191. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/configfile.py +0 -0
  192. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/extras.py +0 -0
  193. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/hotkey.py +0 -0
  194. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/noninteractive.py +0 -0
  195. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/speech.py +0 -0
  196. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/steps.py +0 -0
  197. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/system.py +0 -0
  198. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/setup/ui.py +0 -0
  199. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/sounds/__init__.py +0 -0
  200. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/sounds/alert.wav +0 -0
  201. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/sounds/commit.wav +0 -0
  202. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/sounds/start.wav +0 -0
  203. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/sounds/stop.wav +0 -0
  204. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/spool.py +0 -0
  205. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/streaming.py +0 -0
  206. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/suggest.py +0 -0
  207. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tokens.py +0 -0
  208. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tray/_icons.py +0 -0
  209. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/__init__.py +0 -0
  210. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/base.py +0 -0
  211. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/chain.py +0 -0
  212. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/edge_engine.py +0 -0
  213. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/espeak.py +0 -0
  214. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/piper_engine.py +0 -0
  215. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/player.py +0 -0
  216. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/tts/supertonic_engine.py +0 -0
  217. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/__init__.py +0 -0
  218. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/base.py +0 -0
  219. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/chain.py +0 -0
  220. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/clipboard.py +0 -0
  221. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/collecting.py +0 -0
  222. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/fcitx_bridge.py +0 -0
  223. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/ibus.py +0 -0
  224. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/manager.py +0 -0
  225. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/modifier_guard.py +0 -0
  226. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/paste.py +0 -0
  227. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/pynput_type.py +0 -0
  228. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/wtype.py +0 -0
  229. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/xdotool.py +0 -0
  230. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/typers/ydotool.py +0 -0
  231. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/ui.py +0 -0
  232. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/vad.py +0 -0
  233. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/vocab_stats.py +0 -0
  234. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/vocabulary.py +0 -0
  235. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/watchdog.py +0 -0
  236. {python_voiceio-1.16.1 → python_voiceio-1.17.0}/voiceio/wordfreq.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 1.16.1
3
+ Version: 1.17.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -96,7 +96,7 @@ sudo apt install ./voiceio_*.deb
96
96
  voiceio setup
97
97
  ```
98
98
 
99
- Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
99
+ Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). Doctor offers one when a check fails, and so does the tray's "Report a problem...". A report names the last error only by its type and `file:line`, never its message. NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
100
100
 
101
101
  ## Make it yours
102
102
 
@@ -43,7 +43,7 @@ sudo apt install ./voiceio_*.deb
43
43
  voiceio setup
44
44
  ```
45
45
 
46
- Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
46
+ Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). Doctor offers one when a check fails, and so does the tray's "Report a problem...". A report names the last error only by its type and `file:line`, never its message. NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
47
47
 
48
48
  ## Make it yours
49
49
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "1.16.1"
7
+ version = "1.17.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.16.1
3
+ Version: 1.17.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -96,7 +96,7 @@ sudo apt install ./voiceio_*.deb
96
96
  voiceio setup
97
97
  ```
98
98
 
99
- Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
99
+ Then press your hotkey, speak, and press it again (or hold it while you speak). `voiceio doctor` shows what works, `voiceio doctor --fix` repairs what it can, and `voiceio doctor --report` shares an anonymous install report on the [feedback board](https://voiceio.dev/#feedback) (it shows you the text and asks first). Doctor offers one when a check fails, and so does the tray's "Report a problem...". A report names the last error only by its type and `file:line`, never its message. NixOS, other extras and installing from source: [docs/linux.md](docs/linux.md). Agent runbook: [INSTALL.md](INSTALL.md).
100
100
 
101
101
  ## Make it yours
102
102
 
@@ -24,6 +24,7 @@ tests/test_corrections.py
24
24
  tests/test_dead_mic.py
25
25
  tests/test_decoders.py
26
26
  tests/test_engine.py
27
+ tests/test_errorlog.py
27
28
  tests/test_evdev_uaccess.py
28
29
  tests/test_fallback.py
29
30
  tests/test_fcitx_bridge.py
@@ -101,6 +102,7 @@ voiceio/context_terms.py
101
102
  voiceio/corrections.py
102
103
  voiceio/demo.py
103
104
  voiceio/display.py
105
+ voiceio/errorlog.py
104
106
  voiceio/feedback.py
105
107
  voiceio/focus.py
106
108
  voiceio/health.py
@@ -0,0 +1,136 @@
1
+ """The last error in a report, and the service's repeated-error nudge."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import logging
6
+
7
+ from voiceio import errorlog
8
+
9
+ NOW = dt.datetime(2026, 11, 5, 15, 0, 0)
10
+ SECRET = "please wire the money to my sister on Friday"
11
+ PKG = errorlog._PKG
12
+
13
+
14
+ def _log(tmp_path, text):
15
+ path = tmp_path / "voiceio.log"
16
+ path.write_text(text)
17
+ return path
18
+
19
+
20
+ SERVICE_LOG = f"""\
21
+ 2026-11-05 12:58:10,961 voiceio INFO Recording started
22
+ 2026-11-05 12:58:11,000 voiceio.streaming ERROR Streaming failed on: {SECRET}
23
+ Traceback (most recent call last):
24
+ File "{PKG}/core/decoders/sherpa.py", line 280, in decode
25
+ stream = self._rec.create_stream(hotwords)
26
+ AttributeError: 'NoneType' object has no attribute 'create_stream'
27
+
28
+ The above exception was the direct cause of the following exception:
29
+
30
+ Traceback (most recent call last):
31
+ File "{PKG}/streaming.py", line 488, in _transcribe_and_apply
32
+ result = self._decoder.decode(tail)
33
+ File "/usr/lib/python3/dist-packages/numpy/core.py", line 9, in f
34
+ pass
35
+ voiceio.core.decoders.base.DecodeError: decode failed: {SECRET}
36
+ Word
37
+ {SECRET}
38
+ 2026-11-05 12:58:12,000 voiceio INFO voiceio stopped
39
+ """
40
+
41
+
42
+ def test_last_error_names_only_type_frame_and_age(tmp_path):
43
+ got = errorlog.last_error(_log(tmp_path, SERVICE_LOG), tmp_path / "none", now=NOW)
44
+ assert got == "DecodeError at voiceio/streaming.py:488 (2 h ago)"
45
+
46
+
47
+ def test_dictated_text_in_a_log_never_reaches_the_report(tmp_path):
48
+ from voiceio.health import BackendStatus, HealthReport
49
+ from voiceio.platform import Platform
50
+ from voiceio.report import build_report
51
+ from voiceio.config import Config
52
+
53
+ err = errorlog.last_error(_log(tmp_path, SERVICE_LOG), tmp_path / "none", now=NOW)
54
+ health = HealthReport(
55
+ platform=Platform(os="linux", display_server="wayland", desktop="gnome"),
56
+ hotkey_backends=[BackendStatus("evdev", True)],
57
+ typer_backends=[BackendStatus("ibus", True)], audio_ok=True)
58
+ text = build_report(health, last_error=err, cfg=Config())
59
+ assert "last error: DecodeError at voiceio/streaming.py:488" in text
60
+ for word in ("money", "sister", "Friday", "Word", "NoneType", "create_stream"):
61
+ assert word not in text
62
+
63
+
64
+ def test_an_error_without_traceback_names_its_logger(tmp_path):
65
+ log = f"2026-11-05 14:30:00,000 voiceio.recorder ERROR mic gone: {SECRET}\n"
66
+ got = errorlog.last_error(_log(tmp_path, log), tmp_path / "none", now=NOW)
67
+ assert got == "logged by voiceio.recorder (30 min ago)"
68
+
69
+
70
+ def test_errors_older_than_a_week_are_left_out(tmp_path):
71
+ old = SERVICE_LOG.replace("2026-11-05 12:58:11", "2026-10-20 12:58:11")
72
+ assert errorlog.last_error(_log(tmp_path, old), tmp_path / "none", now=NOW) == ""
73
+
74
+
75
+ def test_crash_log_keeps_the_frame_not_the_argv(tmp_path):
76
+ crash = tmp_path / "crash.log"
77
+ crash.write_text(f"""
78
+ ===== 2026-11-04T14:00:00.123456 =====
79
+ argv: ['voiceio', 'say', '{SECRET}']
80
+ Traceback (most recent call last):
81
+ File "/home/someone/src/voiceio/voiceio/cli/__init__.py", line 205, in _entry_point
82
+ main()
83
+ File "/home/someone/.venv/lib/python3.13/site-packages/numpy/__init__.py", line 1, in <module>
84
+ x
85
+ ImportError: cannot import {SECRET}
86
+ """)
87
+ got = errorlog.last_error(tmp_path / "none", crash, now=NOW)
88
+ assert got == "ImportError at voiceio/cli/__init__.py:205 (1 d ago)"
89
+
90
+
91
+ def test_the_newest_of_service_and_crash_errors_wins(tmp_path):
92
+ crash = tmp_path / "crash.log"
93
+ crash.write_text("===== 2026-11-05T14:59:00 =====\nargv: []\n"
94
+ "Traceback (most recent call last):\n"
95
+ f' File "{PKG}/app.py", line 7, in run\n x\nKeyError: 1\n')
96
+ got = errorlog.last_error(_log(tmp_path, SERVICE_LOG), crash, now=NOW)
97
+ assert got == "KeyError at voiceio/app.py:7 (1 min ago)"
98
+
99
+
100
+ def _record(msg, *args):
101
+ return logging.LogRecord("voiceio.streaming", logging.ERROR, __file__, 1, msg, args, None)
102
+
103
+
104
+ def test_the_same_error_three_times_notifies_once_a_day(tmp_path):
105
+ sent = []
106
+ h = errorlog.RepeatedErrorNotifier(tmp_path / "state.json",
107
+ notify=lambda title, body: sent.append(body))
108
+ for n in range(2):
109
+ h.emit(_record("decode failed: %s", f"words {n}"))
110
+ h.emit(_record("something else"))
111
+ assert sent == []
112
+ h.emit(_record("decode failed: %s", "more words"))
113
+ assert sent == ["Run 'voiceio doctor --report' to tell us."]
114
+ # A restarted daemon reads the same state: no second nudge today.
115
+ h2 = errorlog.RepeatedErrorNotifier(tmp_path / "state.json",
116
+ notify=lambda title, body: sent.append(body))
117
+ for _ in range(3):
118
+ h2.emit(_record("decode failed: %s", "x"))
119
+ assert len(sent) == 1
120
+
121
+
122
+ def test_doctor_offers_a_report_only_after_a_failure_in_a_terminal(monkeypatch):
123
+ import argparse
124
+
125
+ from voiceio import report
126
+ from voiceio.cli.doctor import _offer_report
127
+
128
+ asked = []
129
+ monkeypatch.setattr(report, "_ask", lambda prompt: asked.append(prompt) or "y")
130
+ monkeypatch.setattr(report, "_interactive", lambda: True)
131
+ args = argparse.Namespace(yes=False)
132
+ assert not _offer_report(args, healthy=True)
133
+ assert not _offer_report(argparse.Namespace(yes=True), healthy=False)
134
+ assert _offer_report(args, healthy=False) and "Something isn't working" in asked[0]
135
+ monkeypatch.setattr(report, "_interactive", lambda: False)
136
+ assert not _offer_report(args, healthy=False)
@@ -0,0 +1 @@
1
+ __version__ = "1.17.0"
@@ -125,6 +125,8 @@ def _cmd_run(args: argparse.Namespace) -> None:
125
125
 
126
126
  logging.basicConfig(level=logging.DEBUG, handlers=[console, file_handler])
127
127
  logging.getLogger("voiceio").setLevel(getattr(logging, cfg.daemon.log_level))
128
+ from voiceio.errorlog import RepeatedErrorNotifier
129
+ logging.getLogger("voiceio").addHandler(RepeatedErrorNotifier())
128
130
 
129
131
  log = logging.getLogger("voiceio")
130
132
  from voiceio import __version__, platform as plat
@@ -215,7 +217,8 @@ def _entry_point() -> None:
215
217
  with open(LOG_DIR / "crash.log", "a", encoding="utf-8") as f:
216
218
  f.write(f"\n===== {datetime.datetime.now().isoformat()} =====\n"
217
219
  f"argv: {sys.argv}\n{tb}")
218
- print(f"\n[voiceio crashed — traceback in {LOG_DIR / 'crash.log'}]", file=sys.stderr)
220
+ print(f"\n[voiceio crashed — traceback in {LOG_DIR / 'crash.log'}]\n"
221
+ "Report it: voiceio doctor --report", file=sys.stderr)
219
222
  except Exception: # noqa: BLE001 — last resort
220
223
  pass
221
224
  print(tb, file=sys.stderr)
@@ -86,9 +86,10 @@ def _cmd_doctor(args: argparse.Namespace) -> None:
86
86
  print("Run 'voiceio doctor --fix' to tighten file permissions.")
87
87
  from voiceio.hints import hint
88
88
  hint("vocab_suggest", "Run 'voiceio vocab suggest' to find names it keeps mishearing")
89
- if args.report:
89
+ healthy = report.all_ok and not perm_issues and not missing_deps
90
+ if args.report or _offer_report(args, healthy and not report.warnings):
90
91
  _share_report(args, report, missing_deps, [p for p, _, _ in perm_issues])
91
- sys.exit(0 if (report.all_ok and not perm_issues and not missing_deps) else 1)
92
+ sys.exit(0 if healthy else 1)
92
93
 
93
94
  # Auto-fix mode
94
95
  print("\nAttempting fixes...\n")
@@ -197,21 +198,35 @@ def _cmd_doctor(args: argparse.Namespace) -> None:
197
198
  report = check_health()
198
199
  print(format_report(report))
199
200
 
200
- if args.report:
201
- _share_report(args, report, plat.check_system_deps(),
202
- [p for p, _, _ in _config.check_permissions()])
201
+ missing_deps = plat.check_system_deps()
202
+ perm_issues = _config.check_permissions()
203
+ healthy = report.all_ok and not perm_issues and not missing_deps and not report.warnings
204
+ if args.report or _offer_report(args, healthy):
205
+ _share_report(args, report, missing_deps, [p for p, _, _ in perm_issues])
203
206
  sys.exit(0 if report.all_ok else 1)
204
207
 
205
208
 
209
+ def _offer_report(args: argparse.Namespace, healthy: bool) -> bool:
210
+ """After a failed check, in a terminal, ask once whether to share a
211
+ report (default no). The report is still shown before it is posted."""
212
+ from voiceio.report import _ask, _interactive
213
+ if healthy or args.yes or not _interactive():
214
+ return False
215
+ answer = _ask("\nSomething isn't working. Post a report to the public feedback board? "
216
+ "You'll see it first. [y/N] ")
217
+ return answer.strip().lower() in ("y", "yes")
218
+
219
+
206
220
  def _share_report(args: argparse.Namespace, report, missing_deps: list[str],
207
221
  perm_issues: list) -> None:
222
+ from voiceio.errorlog import last_error
208
223
  from voiceio.report import _interactive, ask_details, build_report, share
209
224
  comment, works = args.comment or "", ""
210
225
  if not comment and not args.yes and _interactive():
211
226
  comment, works = ask_details()
212
227
  try:
213
228
  text = build_report(report, missing_deps=missing_deps, perm_issues=perm_issues,
214
- comment=comment, works=works)
229
+ comment=comment, works=works, last_error=last_error())
215
230
  except Exception as e: # noqa: BLE001 — the report must not take doctor down
216
231
  print(f"\nCould not build the report ({type(e).__name__}); nothing was posted.")
217
232
  return
@@ -0,0 +1,177 @@
1
+ """voiceio's most recent error, in a form safe to post, and a nudge to report
2
+ errors the service hits on its own.
3
+
4
+ Logs and crash tracebacks can quote dictated text (an exception message, an
5
+ argv), so nothing here ever copies a message. A report gets the exception
6
+ type, the innermost voiceio `file:line` and how long ago; a notification
7
+ says only that something failed.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import json
13
+ import logging
14
+ import re
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ MAX_AGE = dt.timedelta(days=7)
20
+ # The same error this many times within the window is worth one notification.
21
+ REPEATS = 3
22
+ REPEAT_WINDOW_SECS = 600
23
+
24
+ _LOG_LINE = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d),\d+ (\S+) (\w+) ")
25
+ _CRASH_HEADER = re.compile(r"^===== (\S+) =====$")
26
+ _FRAME = re.compile(r'^\s+File "([^"]+)", line (\d+)')
27
+ # The final line of a traceback: a dotted exception name, alone or before ":".
28
+ _EXC_LINE = re.compile(r"^([A-Za-z_][\w.]{0,120})(?::|$)")
29
+ _NAME = re.compile(r"^[A-Za-z_][\w.]{0,120}$")
30
+ _PKG = Path(__file__).resolve().parent
31
+
32
+
33
+ def last_error(log_path: Path | None = None, crash_path: Path | None = None,
34
+ now: dt.datetime | None = None) -> str:
35
+ """'DecodeError at voiceio/streaming.py:488 (2 h ago)' for the newest
36
+ error of the last week in the service log or crash.log; '' if none."""
37
+ from voiceio.config import LOG_DIR, LOG_PATH
38
+ now = now or dt.datetime.now()
39
+ found = [e for path, parse in ((log_path or LOG_PATH, _log_errors),
40
+ (crash_path or LOG_DIR / "crash.log", _crash_errors))
41
+ for e in parse(_read(path))]
42
+ recent = [e for e in found if now - e[0] <= MAX_AGE]
43
+ if not recent:
44
+ return ""
45
+ when, exc, where = max(recent, key=lambda e: e[0])
46
+ what = f"{exc} at {where}" if exc and where else exc or f"logged by {where}"
47
+ return f"{what} ({_age(now - when)})"
48
+
49
+
50
+ def _read(path: Path) -> list[str]:
51
+ try:
52
+ return path.read_text(encoding="utf-8", errors="replace").splitlines()
53
+ except OSError:
54
+ return []
55
+
56
+
57
+ def _log_errors(lines: list[str]):
58
+ """(time, exception type, location) per ERROR/CRITICAL in the service log;
59
+ without a traceback the location is the logger's name."""
60
+ for i, line in enumerate(lines):
61
+ m = _LOG_LINE.match(line)
62
+ if not m or m.group(3) not in ("ERROR", "CRITICAL"):
63
+ continue
64
+ tail = []
65
+ for nxt in lines[i + 1:]:
66
+ if _LOG_LINE.match(nxt):
67
+ break
68
+ tail.append(nxt)
69
+ exc, where = _traceback(tail)
70
+ logger = m.group(2) if _NAME.match(m.group(2)) else ""
71
+ yield _time(m.group(1)), exc, where or logger
72
+
73
+
74
+ def _crash_errors(lines: list[str]):
75
+ starts = [(i, m.group(1)) for i, line in enumerate(lines)
76
+ if (m := _CRASH_HEADER.match(line))]
77
+ for n, (i, stamp) in enumerate(starts):
78
+ end = starts[n + 1][0] if n + 1 < len(starts) else len(lines)
79
+ when = _time(stamp)
80
+ if when is not None:
81
+ yield (when, *_traceback(lines[i + 1:end]))
82
+
83
+
84
+ def _traceback(lines: list[str]) -> tuple[str, str]:
85
+ """The last exception's type and its innermost voiceio frame."""
86
+ exc, where, after_frames = "", "", False
87
+ for line in lines:
88
+ if f := _FRAME.match(line):
89
+ after_frames = True
90
+ if rel := _voiceio_path(f.group(1)):
91
+ where = f"{rel}:{f.group(2)}"
92
+ elif line.startswith("Traceback ("):
93
+ exc, where = "", "" # a chained traceback: the last one wins
94
+ elif after_frames and not line.startswith((" ", "\t")):
95
+ # Only the line right after the frames names the exception; the
96
+ # message's own later lines could be anything, dictation included.
97
+ after_frames = False
98
+ if m := _EXC_LINE.match(line):
99
+ exc = m.group(1).rsplit(".", 1)[-1]
100
+ return exc, where
101
+
102
+
103
+ def _voiceio_path(path: str) -> str:
104
+ """'voiceio/app.py' for a file of this package; '' for anything else."""
105
+ try:
106
+ return "voiceio/" + Path(path).resolve().relative_to(_PKG).as_posix()
107
+ except (OSError, ValueError):
108
+ pass
109
+ if "site-packages/" in path: # another install of voiceio, or a library
110
+ rest = path.rsplit("site-packages/", 1)[1]
111
+ return rest if rest.startswith("voiceio/") else ""
112
+ return "voiceio/" + path.rsplit("/voiceio/", 1)[1] if "/voiceio/" in path else ""
113
+
114
+
115
+ def _time(stamp: str) -> dt.datetime | None:
116
+ try:
117
+ when = dt.datetime.fromisoformat(stamp)
118
+ except ValueError:
119
+ return None
120
+ return when.replace(tzinfo=None)
121
+
122
+
123
+ def _age(delta: dt.timedelta) -> str:
124
+ secs = max(0, int(delta.total_seconds()))
125
+ if secs < 3600:
126
+ return f"{max(1, secs // 60)} min ago"
127
+ if secs < 86400:
128
+ return f"{secs // 3600} h ago"
129
+ return f"{secs // 86400} d ago"
130
+
131
+
132
+ class RepeatedErrorNotifier(logging.Handler):
133
+ """The service has no terminal to tell anyone, so when it logs the same
134
+ error REPEATS times in REPEAT_WINDOW_SECS, one desktop notification a day
135
+ suggests a report. Never posts anything itself."""
136
+
137
+ def __init__(self, state_path: Path | None = None, notify=None):
138
+ super().__init__(level=logging.ERROR)
139
+ from voiceio.config import LOG_DIR
140
+ self._path = state_path or LOG_DIR / "error-notify.json"
141
+ self._notify = notify
142
+ self._lock = threading.Lock()
143
+
144
+ def emit(self, record: logging.LogRecord) -> None:
145
+ # The format string, not the message: same error, different details.
146
+ key = f"{record.name}:{record.msg}"[:200]
147
+ now = time.time()
148
+ today = dt.date.today().isoformat()
149
+ try:
150
+ with self._lock:
151
+ state = self._load()
152
+ seen = {k: [t for t in ts if now - t < REPEAT_WINDOW_SECS]
153
+ for k, ts in state["recent"].items() if isinstance(ts, list)}
154
+ seen[key] = [*seen.get(key, []), now][-REPEATS:]
155
+ state["recent"] = {k: ts for k, ts in seen.items() if ts}
156
+ fire = len(seen[key]) >= REPEATS and state.get("notified") != today
157
+ if fire:
158
+ state["notified"] = today
159
+ self._path.parent.mkdir(parents=True, exist_ok=True)
160
+ self._path.write_text(json.dumps(state))
161
+ if fire:
162
+ notify = self._notify
163
+ if notify is None:
164
+ from voiceio.feedback import notify
165
+ notify("voiceio hit an error",
166
+ "Run 'voiceio doctor --report' to tell us.")
167
+ except Exception: # noqa: BLE001 — logging must never fail the daemon
168
+ pass
169
+
170
+ def _load(self) -> dict:
171
+ try:
172
+ state = json.loads(self._path.read_text())
173
+ if isinstance(state, dict) and isinstance(state.get("recent"), dict):
174
+ return state
175
+ except (OSError, ValueError):
176
+ pass
177
+ return {"recent": {}}
@@ -133,6 +133,7 @@ def build_report(
133
133
  perm_issues: Sequence[Path] = (),
134
134
  comment: str = "",
135
135
  works: str = "",
136
+ last_error: str = "",
136
137
  cfg=None,
137
138
  ) -> str:
138
139
  """The exact public text: short, plain, and scrubbed."""
@@ -197,6 +198,8 @@ def build_report(
197
198
  lines.append("failed: " + "; ".join(failed) if failed else "failed: none")
198
199
  if warned:
199
200
  lines.append("warnings: " + "; ".join(warned))
201
+ if last_error:
202
+ lines.append(f"last error: {_short(last_error)}")
200
203
  comment = "".join(c if c.isprintable() else " " for c in _ESCAPE.sub("", comment))
201
204
  if works in WORKS:
202
205
  lines.append(f"works: {works}")
@@ -305,6 +308,7 @@ def gather(comment: str = "") -> tuple[HealthReport, str]:
305
308
  """Run the doctor checks and build the report from them."""
306
309
  from voiceio import config
307
310
  from voiceio import platform as plat
311
+ from voiceio.errorlog import last_error
308
312
  from voiceio.health import check_health
309
313
  health = check_health()
310
314
  return health, build_report(
@@ -312,6 +316,7 @@ def gather(comment: str = "") -> tuple[HealthReport, str]:
312
316
  missing_deps=plat.check_system_deps(),
313
317
  perm_issues=[p for p, _, _ in config.check_permissions()],
314
318
  comment=comment,
319
+ last_error=last_error(),
315
320
  )
316
321
 
317
322
 
@@ -127,6 +127,7 @@ _MENU_COMMANDS: dict[str, list[str]] = {
127
127
  "history": ["voiceio", "history"],
128
128
  "demo": ["voiceio", "demo"],
129
129
  "doctor": ["voiceio", "doctor"],
130
+ "report": ["voiceio", "doctor", "--report"],
130
131
  "logs": ["voiceio", "service", "logs"],
131
132
  }
132
133
 
@@ -86,6 +86,10 @@ def main() -> None:
86
86
  doctor_item.connect("activate", _emit("menu:doctor"))
87
87
  menu.append(doctor_item)
88
88
 
89
+ report_item = Gtk.MenuItem(label="Report a problem...")
90
+ report_item.connect("activate", _emit("menu:report"))
91
+ menu.append(report_item)
92
+
89
93
  logs_item = Gtk.MenuItem(label="View logs...")
90
94
  logs_item.connect("activate", _emit("menu:logs"))
91
95
  menu.append(logs_item)
@@ -61,6 +61,7 @@ def start(
61
61
  pystray.Menu.SEPARATOR,
62
62
  pystray.MenuItem("Demo...", _open_terminal("demo")),
63
63
  pystray.MenuItem("Doctor...", _open_terminal("doctor")),
64
+ pystray.MenuItem("Report a problem...", _open_terminal("report")),
64
65
  pystray.MenuItem("View logs...", _open_terminal("logs")),
65
66
  pystray.Menu.SEPARATOR,
66
67
  pystray.MenuItem("Quit", lambda: quit_callback()),
@@ -1 +0,0 @@
1
- __version__ = "1.16.1"
File without changes