python-voiceio 0.9.9__tar.gz → 0.9.10__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 (130) hide show
  1. {python_voiceio-0.9.9/python_voiceio.egg-info → python_voiceio-0.9.10}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.9 → python_voiceio-0.9.10/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_robustness.py +37 -0
  5. python_voiceio-0.9.10/voiceio/__init__.py +1 -0
  6. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/app.py +58 -4
  7. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/feedback.py +15 -10
  8. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/recorder.py +11 -0
  9. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/__init__.py +29 -0
  10. python_voiceio-0.9.9/voiceio/__init__.py +0 -1
  11. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/LICENSE +0 -0
  12. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/README.md +0 -0
  13. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/SOURCES.txt +0 -0
  14. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/dependency_links.txt +0 -0
  15. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/entry_points.txt +0 -0
  16. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/requires.txt +0 -0
  17. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/top_level.txt +0 -0
  18. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/setup.cfg +0 -0
  19. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_adjudicate.py +0 -0
  20. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_app_wiring.py +0 -0
  21. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_audio_quality.py +0 -0
  22. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_audit.py +0 -0
  23. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_autocorrect.py +0 -0
  24. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_autocorrect_state.py +0 -0
  25. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_backend_probes.py +0 -0
  26. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_cli.py +0 -0
  27. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_clipboard_read.py +0 -0
  28. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_commands.py +0 -0
  29. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_concurrency_lockdown.py +0 -0
  30. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_config.py +0 -0
  31. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_correct_batch.py +0 -0
  32. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_corrections.py +0 -0
  33. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_evaluate.py +0 -0
  34. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_fallback.py +0 -0
  35. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_health.py +0 -0
  36. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_hints.py +0 -0
  37. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_history.py +0 -0
  38. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_pending.py +0 -0
  39. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_ping.py +0 -0
  40. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_typer.py +0 -0
  41. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_llm.py +0 -0
  42. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_llm_api.py +0 -0
  43. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_numbers.py +0 -0
  44. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_platform.py +0 -0
  45. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_postcorrect.py +0 -0
  46. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_postprocess.py +0 -0
  47. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_prebuffer.py +0 -0
  48. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_prompt.py +0 -0
  49. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_recorder_integration.py +0 -0
  50. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_retention.py +0 -0
  51. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_security_hardening.py +0 -0
  52. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_snapshots.py +0 -0
  53. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_streaming.py +0 -0
  54. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_tokens.py +0 -0
  55. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_transcriber.py +0 -0
  56. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_tts.py +0 -0
  57. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_vad.py +0 -0
  58. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_vocabulary.py +0 -0
  59. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_wizard.py +0 -0
  60. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_wordfreq.py +0 -0
  61. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/__main__.py +0 -0
  62. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/audit.py +0 -0
  63. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/autocorrect.py +0 -0
  64. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/autocorrect_state.py +0 -0
  65. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/backends.py +0 -0
  66. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/cli.py +0 -0
  67. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/clipboard_read.py +0 -0
  68. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/commands.py +0 -0
  69. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/config.py +0 -0
  70. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/consent.py +0 -0
  71. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/corrections.py +0 -0
  72. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/demo.py +0 -0
  73. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/evaluate.py +0 -0
  74. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/health.py +0 -0
  75. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hints.py +0 -0
  76. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/history.py +0 -0
  77. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/__init__.py +0 -0
  78. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/base.py +0 -0
  79. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/chain.py +0 -0
  80. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/evdev.py +0 -0
  81. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/pynput_backend.py +0 -0
  82. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/socket_backend.py +0 -0
  83. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/__init__.py +0 -0
  84. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/engine.py +0 -0
  85. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/pending.py +0 -0
  86. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/llm.py +0 -0
  87. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/llm_api.py +0 -0
  88. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/models/__init__.py +0 -0
  89. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/models/silero_vad.onnx +0 -0
  90. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/numbers.py +0 -0
  91. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/pidlock.py +0 -0
  92. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/platform.py +0 -0
  93. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/postcorrect.py +0 -0
  94. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/postprocess.py +0 -0
  95. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/prompt.py +0 -0
  96. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/retention.py +0 -0
  97. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/service.py +0 -0
  98. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/snapshots.py +0 -0
  99. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/__init__.py +0 -0
  100. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/commit.wav +0 -0
  101. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/start.wav +0 -0
  102. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/stop.wav +0 -0
  103. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/streaming.py +0 -0
  104. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tokens.py +0 -0
  105. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/transcriber.py +0 -0
  106. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_icons.py +0 -0
  107. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_indicator.py +0 -0
  108. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_pystray.py +0 -0
  109. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/__init__.py +0 -0
  110. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/base.py +0 -0
  111. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/chain.py +0 -0
  112. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/edge_engine.py +0 -0
  113. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/espeak.py +0 -0
  114. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/piper_engine.py +0 -0
  115. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/player.py +0 -0
  116. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/__init__.py +0 -0
  117. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/base.py +0 -0
  118. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/chain.py +0 -0
  119. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/clipboard.py +0 -0
  120. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/ibus.py +0 -0
  121. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/pynput_type.py +0 -0
  122. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/wtype.py +0 -0
  123. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/xdotool.py +0 -0
  124. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/ydotool.py +0 -0
  125. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vad.py +0 -0
  126. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vocab_stats.py +0 -0
  127. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vocabulary.py +0 -0
  128. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/wizard.py +0 -0
  129. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/wordfreq.py +0 -0
  130. {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.9
3
+ Version: 0.9.10
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.9.9"
7
+ version = "0.9.10"
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: 0.9.9
3
+ Version: 0.9.10
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -163,6 +163,43 @@ class TestAutoStopNotification:
163
163
  assert not notify.called
164
164
 
165
165
 
166
+ class TestNothingCapturedWarning:
167
+ """A muted mic must be reported when the recording ENDS — the moment the
168
+ user looks for text and finds none — however it was stopped."""
169
+
170
+ def test_warns_when_no_signal_and_no_text(self):
171
+ vio, _, _ = _make_vio()
172
+ vio.recorder._heard_signal = False
173
+ with patch("voiceio.feedback.notify") as notify:
174
+ vio._warn_if_nothing_captured("", vio._generation)
175
+ assert notify.called
176
+ assert "mute" in " ".join(notify.call_args[0]).lower()
177
+ assert notify.call_args.kwargs.get("urgent") is True # must persist
178
+
179
+ def test_quiet_when_text_was_produced(self):
180
+ vio, _, _ = _make_vio()
181
+ vio.recorder._heard_signal = False
182
+ with patch("voiceio.feedback.notify") as notify:
183
+ vio._warn_if_nothing_captured("[voice] hello there", vio._generation)
184
+ assert not notify.called
185
+
186
+ def test_quiet_when_mic_had_signal(self):
187
+ """Mic works, user just said nothing — not a fault, stay quiet."""
188
+ vio, _, _ = _make_vio()
189
+ vio.recorder._heard_signal = True
190
+ with patch("voiceio.feedback.notify") as notify:
191
+ vio._warn_if_nothing_captured("", vio._generation)
192
+ assert not notify.called
193
+
194
+ def test_superseded_generation_does_not_warn(self):
195
+ """A newer recording owns the recorder — don't judge this take by it."""
196
+ vio, _, _ = _make_vio()
197
+ vio.recorder._heard_signal = False
198
+ with patch("voiceio.feedback.notify") as notify:
199
+ vio._warn_if_nothing_captured("", vio._generation - 1)
200
+ assert not notify.called
201
+
202
+
166
203
  # ===========================================================================
167
204
  # 1. stream_health() tests
168
205
  # ===========================================================================
@@ -0,0 +1 @@
1
+ __version__ = "0.9.10"
@@ -253,6 +253,9 @@ class VoiceIO:
253
253
  # system is not a zombie) and a guard against concurrent spawns
254
254
  # (health loop vs record-start).
255
255
  self._engine_ping_fails = 0
256
+ # Engine stderr goes to a file (never a pipe — see _spawn_ibus_engine).
257
+ self._engine_stderr_file = None
258
+ self._engine_stderr_path = None
256
259
  self._engine_spawn_lock = threading.Lock()
257
260
  self._shutdown = threading.Event()
258
261
  # IBus input-source ownership. We claim the GNOME input source ONLY
@@ -308,6 +311,29 @@ class VoiceIO:
308
311
  )
309
312
  threading.Thread(target=self._request_stop, daemon=True).start()
310
313
 
314
+ def _warn_if_nothing_captured(self, final_text: str, gen: int) -> None:
315
+ """Warn when a finished recording captured no audio at all.
316
+
317
+ This is the moment the user actually notices — they look for their text
318
+ and nothing appeared. The pre-flight warning is a 3s toast they talk
319
+ straight through, and the 20s no-speech auto-stop never fires on a
320
+ shorter take stopped by hand, which is exactly how a muted mic slips
321
+ by silently. Generation-guarded: a newer recording owns the recorder,
322
+ so never read its state to judge this one.
323
+ """
324
+ if self._generation != gen:
325
+ return
326
+ if final_text.strip() or self.recorder.heard_signal:
327
+ return
328
+ log.warning("Recording captured no audio at all — mic muted?")
329
+ from voiceio import feedback
330
+ feedback.notify(
331
+ "VoiceIO: microphone is muted — nothing recorded",
332
+ "No audio reached voiceio for that entire recording. Check your "
333
+ "mic's mute button and which input device is selected.",
334
+ urgent=True,
335
+ )
336
+
311
337
  def _request_stop(self) -> None:
312
338
  """Stop recording if active. Unlike on_hotkey, never starts."""
313
339
  with self._hotkey_lock:
@@ -418,6 +444,7 @@ class VoiceIO:
418
444
  feedback.notify(
419
445
  "VoiceIO: microphone unavailable",
420
446
  "Could not reopen the audio stream — check your input device.",
447
+ urgent=True,
421
448
  )
422
449
  return
423
450
  if not self.recorder.has_signal():
@@ -426,6 +453,7 @@ class VoiceIO:
426
453
  feedback.notify(
427
454
  "VoiceIO: microphone appears silent",
428
455
  "Check that your mic is unmuted and the right input device is selected.",
456
+ urgent=True,
429
457
  )
430
458
  elif not self.recorder.has_signal():
431
459
  log.warning("Mic appears silent or muted (pre-buffer is all zeros)")
@@ -639,6 +667,7 @@ class VoiceIO:
639
667
  "passes": list(session.trace),
640
668
  })
641
669
  log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
670
+ self._warn_if_nothing_captured(final_text, gen)
642
671
  # Release the IBus input source now that the final commit is done.
643
672
  # Generation-checked inside: if a newer recording started, it already
644
673
  # re-claimed the source and we must not restore it out from under it.
@@ -989,6 +1018,18 @@ class VoiceIO:
989
1018
  except Exception:
990
1019
  log.exception("IBus engine respawn at record start failed")
991
1020
 
1021
+ def _engine_stderr_tail(self, limit: int = 500) -> str:
1022
+ """Last bytes of the engine's stderr file, for crash reporting."""
1023
+ path = getattr(self, "_engine_stderr_path", None)
1024
+ if path is None:
1025
+ return ""
1026
+ try:
1027
+ if self._engine_stderr_file is not None:
1028
+ self._engine_stderr_file.flush()
1029
+ return path.read_text(errors="replace").strip()[-limit:]
1030
+ except OSError:
1031
+ return ""
1032
+
992
1033
  def _ensure_ibus_engine(self, activate: bool = True) -> None:
993
1034
  """Start the VoiceIO IBus engine, optionally pre-creating its instance.
994
1035
 
@@ -1009,6 +1050,7 @@ class VoiceIO:
1009
1050
  self._engine_spawn_lock.release()
1010
1051
 
1011
1052
  def _spawn_ibus_engine(self, activate: bool = True) -> None:
1053
+ from voiceio.config import LOG_DIR
1012
1054
  from voiceio.ibus import READY_PATH, SOCKET_PATH
1013
1055
  from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
1014
1056
 
@@ -1032,12 +1074,24 @@ class VoiceIO:
1032
1074
  self._kill_stale_engine(SOCKET_PATH)
1033
1075
  READY_PATH.unlink(missing_ok=True)
1034
1076
 
1035
- # Spawn the engine process directly
1077
+ # Spawn the engine process directly. stderr goes to a FILE, never a
1078
+ # pipe: the engine logs at DEBUG through a StreamHandler, and a piped,
1079
+ # undrained stderr fills its 64KB buffer and blocks the engine
1080
+ # mid-write — alive but frozen, so it stops answering pings and the
1081
+ # watchdog reaps it (the recurring "not responding to ping" churn).
1082
+ # The worker already does it this way.
1036
1083
  log.info("Starting VoiceIO IBus engine...")
1084
+ stderr_target = subprocess.DEVNULL
1085
+ try:
1086
+ self._engine_stderr_path = LOG_DIR / "ibus-engine-stderr.log"
1087
+ self._engine_stderr_file = open(self._engine_stderr_path, "w")
1088
+ stderr_target = self._engine_stderr_file
1089
+ except OSError:
1090
+ self._engine_stderr_path = None
1037
1091
  try:
1038
1092
  self._engine_proc = subprocess.Popen(
1039
1093
  [str(LAUNCHER_PATH)],
1040
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1094
+ stdout=subprocess.DEVNULL, stderr=stderr_target,
1041
1095
  env=ibus_env,
1042
1096
  )
1043
1097
  except OSError as e:
@@ -1052,8 +1106,8 @@ class VoiceIO:
1052
1106
  time.sleep(0.1)
1053
1107
  else:
1054
1108
  if self._engine_proc.poll() is not None:
1055
- stderr = self._engine_proc.stderr.read().decode(errors="replace") if self._engine_proc.stderr else ""
1056
- log.error("IBus engine crashed (rc=%d): %s", self._engine_proc.returncode, stderr.strip()[-500:])
1109
+ log.error("IBus engine crashed (rc=%d): %s",
1110
+ self._engine_proc.returncode, self._engine_stderr_tail())
1057
1111
  else:
1058
1112
  log.warning("IBus engine started but socket not found, commands may fail")
1059
1113
  return
@@ -106,10 +106,14 @@ def _play_sync(name: str) -> None:
106
106
  log.debug("Playback failed for %s", name, exc_info=True)
107
107
 
108
108
 
109
- def notify(title: str, body: str) -> None:
110
- """Send a desktop notification without blocking the caller."""
109
+ def notify(title: str, body: str, *, urgent: bool = False) -> None:
110
+ """Send a desktop notification without blocking the caller.
111
+
112
+ ``urgent=True`` marks it critical so the desktop keeps it on screen until
113
+ dismissed — for actionable failures (a muted mic) that a 3s toast loses.
114
+ """
111
115
  threading.Thread(
112
- target=_send_notification, args=(title, body), daemon=True,
116
+ target=_send_notification, args=(title, body, urgent), daemon=True,
113
117
  ).start()
114
118
 
115
119
 
@@ -118,7 +122,7 @@ def notify_clipboard(text: str) -> None:
118
122
  notify("VoiceIO: copied to clipboard", preview)
119
123
 
120
124
 
121
- def _send_notification(title: str, body: str) -> None:
125
+ def _send_notification(title: str, body: str, urgent: bool = False) -> None:
122
126
  import sys
123
127
 
124
128
  if sys.platform == "win32":
@@ -126,17 +130,18 @@ def _send_notification(title: str, body: str) -> None:
126
130
  elif sys.platform == "darwin":
127
131
  _send_notification_macos(title, body)
128
132
  else:
129
- _send_notification_linux(title, body)
133
+ _send_notification_linux(title, body, urgent)
130
134
 
131
135
 
132
- def _send_notification_linux(title: str, body: str) -> None:
136
+ def _send_notification_linux(title: str, body: str, urgent: bool = False) -> None:
133
137
  if not _which("notify-send"):
134
138
  return
139
+ # Critical urgency makes the desktop hold the notification until dismissed;
140
+ # a normal 3s toast is missed while the user is mid-dictation.
141
+ args = ["notify-send", "--app-name=VoiceIO"]
142
+ args += ["-u", "critical"] if urgent else ["-t", "3000"]
135
143
  try:
136
- subprocess.run(
137
- ["notify-send", "--app-name=VoiceIO", "-t", "3000", title, body],
138
- capture_output=True, timeout=3,
139
- )
144
+ subprocess.run([*args, title, body], capture_output=True, timeout=3)
140
145
  except (subprocess.TimeoutExpired, OSError):
141
146
  pass
142
147
 
@@ -185,6 +185,17 @@ class AudioRecorder:
185
185
  return False # ring not yet full — can't judge
186
186
  return not self.has_signal()
187
187
 
188
+ @property
189
+ def heard_signal(self) -> bool:
190
+ """True if ANY real audio arrived during the recording just captured.
191
+
192
+ Latches on the first non-silent chunk and is reset by start(), so after
193
+ stop() it answers "did the mic actually deliver anything?" — False means
194
+ a muted or dead mic for the whole take, whether it ended by hotkey or
195
+ auto-stop.
196
+ """
197
+ return self._heard_signal
198
+
188
199
  def has_signal(self) -> bool:
189
200
  """Check if the pre-buffer ring contains non-silence audio.
190
201
 
@@ -131,6 +131,27 @@ _MENU_COMMANDS: dict[str, list[str]] = {
131
131
  }
132
132
 
133
133
 
134
+ def _read_stderr(proc: subprocess.Popen) -> None:
135
+ """Drain the indicator's stderr so it can never block on a full pipe.
136
+
137
+ GTK/AppIndicator emit warnings continuously. With stderr piped and nobody
138
+ reading it, the 64KB pipe buffer fills and the tray process blocks forever
139
+ inside write() — the process stays "alive" (so is_alive() sees nothing
140
+ wrong) while its GLib main loop is frozen, which silently kills the icon
141
+ animation, the menu, AND the registration watchdog that would otherwise
142
+ re-register a dropped icon. Observed live: wchan=anon_pipe_write, icon gone
143
+ for ~2 days. Logged at debug so the diagnostics survive without noise.
144
+ """
145
+ try:
146
+ while proc.poll() is None and proc.stderr:
147
+ line = proc.stderr.readline()
148
+ if not line:
149
+ break
150
+ log.debug("tray: %s", line.decode(errors="replace").rstrip())
151
+ except (OSError, ValueError):
152
+ pass
153
+
154
+
134
155
  def _read_stdout(proc: subprocess.Popen, toggle_cb: Callable[[], None]) -> None:
135
156
  """Read toggle/menu commands from indicator subprocess stdout."""
136
157
  from voiceio.platform import open_in_terminal
@@ -194,6 +215,14 @@ def start(
194
215
  )
195
216
  _backend = "indicator"
196
217
 
218
+ # Always drain stderr — unconditionally, and regardless of whether
219
+ # a toggle callback wants stdout. An undrained pipe blocks the tray
220
+ # process mid-write and freezes its GLib loop (see _read_stderr).
221
+ threading.Thread(
222
+ target=_read_stderr, args=(_proc,), daemon=True,
223
+ name="tray-stderr",
224
+ ).start()
225
+
197
226
  if toggle_callback is not None:
198
227
  _stdout_thread = threading.Thread(
199
228
  target=_read_stdout,
@@ -1 +0,0 @@
1
- __version__ = "0.9.9"
File without changes