python-voiceio 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {python_voiceio-0.3.2/python_voiceio.egg-info → python_voiceio-0.3.3}/PKG-INFO +1 -1
  2. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/pyproject.toml +1 -1
  3. {python_voiceio-0.3.2 → python_voiceio-0.3.3/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_app_wiring.py +6 -1
  5. python_voiceio-0.3.3/voiceio/__init__.py +1 -0
  6. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/app.py +85 -0
  7. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/ibus/engine.py +10 -0
  8. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/recorder.py +54 -0
  9. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/streaming.py +8 -2
  10. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tray/__init__.py +48 -0
  11. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/ibus.py +41 -1
  12. python_voiceio-0.3.2/voiceio/__init__.py +0 -1
  13. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/LICENSE +0 -0
  14. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/README.md +0 -0
  15. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
  16. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
  17. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/python_voiceio.egg-info/entry_points.txt +0 -0
  18. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/python_voiceio.egg-info/requires.txt +0 -0
  19. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/python_voiceio.egg-info/top_level.txt +0 -0
  20. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/setup.cfg +0 -0
  21. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_autocorrect.py +0 -0
  22. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_backend_probes.py +0 -0
  23. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_clipboard_read.py +0 -0
  24. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_commands.py +0 -0
  25. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_config.py +0 -0
  26. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_corrections.py +0 -0
  27. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_fallback.py +0 -0
  28. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_health.py +0 -0
  29. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_hints.py +0 -0
  30. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_history.py +0 -0
  31. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_ibus_typer.py +0 -0
  32. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_llm.py +0 -0
  33. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_llm_api.py +0 -0
  34. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_numbers.py +0 -0
  35. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_platform.py +0 -0
  36. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_postprocess.py +0 -0
  37. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_prebuffer.py +0 -0
  38. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_prompt.py +0 -0
  39. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_recorder_integration.py +0 -0
  40. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_streaming.py +0 -0
  41. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_transcriber.py +0 -0
  42. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_tts.py +0 -0
  43. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_vad.py +0 -0
  44. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_vocabulary.py +0 -0
  45. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/tests/test_wordfreq.py +0 -0
  46. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/__main__.py +0 -0
  47. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/autocorrect.py +0 -0
  48. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/backends.py +0 -0
  49. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/cli.py +0 -0
  50. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/clipboard_read.py +0 -0
  51. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/commands.py +0 -0
  52. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/config.py +0 -0
  53. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/corrections.py +0 -0
  54. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/demo.py +0 -0
  55. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/feedback.py +0 -0
  56. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/health.py +0 -0
  57. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hints.py +0 -0
  58. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/history.py +0 -0
  59. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/__init__.py +0 -0
  60. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/base.py +0 -0
  61. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/chain.py +0 -0
  62. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/evdev.py +0 -0
  63. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/pynput_backend.py +0 -0
  64. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/hotkeys/socket_backend.py +0 -0
  65. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/ibus/__init__.py +0 -0
  66. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/llm.py +0 -0
  67. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/llm_api.py +0 -0
  68. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/models/__init__.py +0 -0
  69. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/models/silero_vad.onnx +0 -0
  70. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/numbers.py +0 -0
  71. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/pidlock.py +0 -0
  72. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/platform.py +0 -0
  73. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/postprocess.py +0 -0
  74. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/prompt.py +0 -0
  75. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/service.py +0 -0
  76. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/sounds/__init__.py +0 -0
  77. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/sounds/commit.wav +0 -0
  78. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/sounds/start.wav +0 -0
  79. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/sounds/stop.wav +0 -0
  80. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/transcriber.py +0 -0
  81. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tray/_icons.py +0 -0
  82. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tray/_indicator.py +0 -0
  83. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tray/_pystray.py +0 -0
  84. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/__init__.py +0 -0
  85. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/base.py +0 -0
  86. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/chain.py +0 -0
  87. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/edge_engine.py +0 -0
  88. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/espeak.py +0 -0
  89. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/piper_engine.py +0 -0
  90. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/tts/player.py +0 -0
  91. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/__init__.py +0 -0
  92. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/base.py +0 -0
  93. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/chain.py +0 -0
  94. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/clipboard.py +0 -0
  95. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/pynput_type.py +0 -0
  96. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/wtype.py +0 -0
  97. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/xdotool.py +0 -0
  98. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/typers/ydotool.py +0 -0
  99. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/vad.py +0 -0
  100. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/vocabulary.py +0 -0
  101. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/wizard.py +0 -0
  102. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/wordfreq.py +0 -0
  103. {python_voiceio-0.3.2 → python_voiceio-0.3.3}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: 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.3.2"
7
+ version = "0.3.3"
8
8
  description = "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.3.2
3
+ Version: 0.3.3
4
4
  Summary: Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -28,7 +28,12 @@ def _make_vio(mock_transcriber=None):
28
28
 
29
29
  from voiceio.app import VoiceIO
30
30
  vio = VoiceIO(Config())
31
- vio.recorder._stream = MagicMock() # skip real audio
31
+ mock_stream = MagicMock()
32
+ mock_stream.active = True
33
+ mock_stream.closed = False
34
+ mock_stream.stopped = False
35
+ vio.recorder._stream = mock_stream # skip real audio
36
+ vio.recorder._last_callback_time = time.monotonic() # healthy heartbeat
32
37
  return vio, mock_typer, mock_transcriber
33
38
 
34
39
 
@@ -0,0 +1 @@
1
+ __version__ = "0.3.3"
@@ -8,6 +8,7 @@ import signal
8
8
  import subprocess
9
9
  import threading
10
10
  import time
11
+ from typing import TYPE_CHECKING
11
12
 
12
13
  import numpy as np
13
14
 
@@ -22,6 +23,9 @@ from voiceio.transcriber import Transcriber
22
23
  from voiceio.typers import chain as typer_chain
23
24
  from voiceio.vad import load_vad
24
25
  from voiceio.vocabulary import load_vocabulary
26
+
27
+ if TYPE_CHECKING:
28
+ from voiceio.typers.base import TyperBackend
25
29
  log = logging.getLogger("voiceio")
26
30
  _DEBOUNCE_SECS = 0.3
27
31
 
@@ -117,6 +121,10 @@ class VoiceIO:
117
121
  self._engine_proc: subprocess.Popen | None = None
118
122
  self._shutdown = threading.Event()
119
123
 
124
+ # Audio stream recovery backoff
125
+ self._stream_fail_count = 0
126
+ self._next_stream_retry: float = 0
127
+
120
128
  def request_shutdown(self) -> None:
121
129
  """Request graceful shutdown from an external signal handler."""
122
130
  self._shutdown.set()
@@ -169,6 +177,19 @@ class VoiceIO:
169
177
 
170
178
  def _do_start(self) -> None:
171
179
  """Transition to RECORDING."""
180
+ # Pre-flight: ensure audio stream is healthy before recording
181
+ ok, reason = self.recorder.stream_health()
182
+ if not ok:
183
+ log.warning("Audio stream unhealthy before recording: %s — reopening", reason)
184
+ try:
185
+ self.recorder.reopen_stream()
186
+ except Exception:
187
+ log.exception("Cannot reopen audio stream, aborting recording")
188
+ return
189
+
190
+ if not self.recorder.has_signal():
191
+ log.warning("Mic appears silent or muted (pre-buffer is all zeros)")
192
+
172
193
  self._state = _State.RECORDING
173
194
  self._activate_ibus()
174
195
  self._corrections.load() # hot-reload corrections on each recording
@@ -452,6 +473,30 @@ class VoiceIO:
452
473
  self._set_gnome_input_source_index(0)
453
474
  log.info("VoiceIO IBus engine ready (dormant until recording)")
454
475
 
476
+ def _wait_for_ibus(self, chain: list[str]) -> TyperBackend | None:
477
+ """Wait for IBus daemon to become available and switch to IBus typer.
478
+
479
+ At session startup, voiceio may start before ibus-daemon is ready.
480
+ This polls briefly so we can use IBus instead of a broken fallback.
481
+ """
482
+ from voiceio.typers.ibus import _ibus_daemon_running
483
+
484
+ log.info("IBus preferred but not available yet, waiting for ibus-daemon...")
485
+ for i in range(30): # up to ~15 seconds
486
+ time.sleep(0.5)
487
+ if _ibus_daemon_running():
488
+ log.info("IBus daemon ready after %.1fs, re-probing typers", (i + 1) * 0.5)
489
+ try:
490
+ typer = typer_chain.select(self.platform, self.cfg.output.method)
491
+ if typer.name == "ibus":
492
+ self._ensure_ibus_engine()
493
+ return typer
494
+ except RuntimeError:
495
+ pass
496
+ break
497
+ log.warning("IBus daemon did not start in time, using fallback typer: %s", self._typer.name)
498
+ return None
499
+
455
500
  @staticmethod
456
501
  def _kill_stale_engine(socket_path) -> None:
457
502
  socket_path.unlink(missing_ok=True)
@@ -566,6 +611,39 @@ class VoiceIO:
566
611
  self._state = _State.ERROR
567
612
  tray.set_error(True)
568
613
 
614
+ # Check audio stream (covers ALSA underrun, PulseAudio/PipeWire
615
+ # restart, device disconnect, stale callback heartbeat)
616
+ ok, reason = self.recorder.stream_health()
617
+ if not ok:
618
+ now = time.monotonic()
619
+ if now < self._next_stream_retry:
620
+ return # backoff: skip this cycle
621
+ log.warning("Audio stream unhealthy: %s — reopening (attempt %d)",
622
+ reason, self._stream_fail_count + 1)
623
+ try:
624
+ self.recorder.reopen_stream()
625
+ self._stream_fail_count = 0
626
+ self._next_stream_retry = 0
627
+ tray.set_error(False)
628
+ log.info("Audio stream recovered")
629
+ except Exception:
630
+ self._stream_fail_count += 1
631
+ # Backoff: 10s, 20s, 40s, 80s, max 5min
632
+ delay = min(10 * (2 ** (self._stream_fail_count - 1)), 300)
633
+ self._next_stream_retry = now + delay
634
+ tray.set_error(True)
635
+ log.error("Audio stream recovery failed (retry in %ds)", delay)
636
+ elif self._stream_fail_count > 0:
637
+ # Stream recovered externally (e.g. device plugged back in)
638
+ self._stream_fail_count = 0
639
+ self._next_stream_retry = 0
640
+ tray.set_error(False)
641
+
642
+ # Check tray subprocess (restart if died / lost D-Bus registration)
643
+ if self.cfg.tray.enabled and not tray.is_alive():
644
+ log.warning("Tray subprocess died, restarting")
645
+ tray.restart(self.on_hotkey)
646
+
569
647
  # Check IBus engine (restart if died)
570
648
  if self._typer.name == "ibus" and self._engine_proc is not None:
571
649
  if self._engine_proc.poll() is not None:
@@ -598,6 +676,13 @@ class VoiceIO:
598
676
 
599
677
  if self._typer.name == "ibus":
600
678
  self._ensure_ibus_engine()
679
+ else:
680
+ # IBus daemon may not be ready at startup (race with graphical
681
+ # session). If IBus is in the preferred chain but wasn't selected,
682
+ # wait briefly and re-probe.
683
+ chain = typer_chain._get_chain(self.platform)
684
+ if "ibus" in chain and self._typer.name != "ibus":
685
+ self._typer = self._wait_for_ibus(chain) or self._typer
601
686
 
602
687
  self.recorder.open_stream()
603
688
 
@@ -147,6 +147,16 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
147
147
  pass
148
148
  continue
149
149
 
150
+ if msg == "focus?":
151
+ # Quick focus query — no need to go through GLib
152
+ if addr and _engine is not None:
153
+ reply = b"focused" if _engine._focused else b"unfocused"
154
+ try:
155
+ sock.sendto(reply, addr)
156
+ except OSError:
157
+ pass
158
+ continue
159
+
150
160
  # Dispatch to engine on GLib main thread
151
161
  GLib.idle_add(_handle_command, msg)
152
162
 
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import logging
5
5
  import threading
6
+ import time
6
7
  from typing import TYPE_CHECKING, Callable
7
8
 
8
9
  import numpy as np
@@ -107,6 +108,9 @@ class AudioRecorder:
107
108
  self._heard_speech = False
108
109
  self._on_auto_stop: Callable[[], None] | None = None
109
110
 
111
+ # Heartbeat: updated by _callback, checked by health watchdog
112
+ self._last_callback_time: float = 0.0
113
+
110
114
  def open_stream(self) -> None:
111
115
  """Start the always-on audio stream (feeds ring buffer)."""
112
116
  if self._stream is not None:
@@ -121,6 +125,54 @@ class AudioRecorder:
121
125
  self._stream.start()
122
126
  log.debug("Audio stream opened (prebuffer=%.1fs)", self.prebuffer_secs)
123
127
 
128
+ # Maximum seconds between callbacks before we consider the stream dead.
129
+ # PortAudio typically fires every ~50-100ms; 5s covers long system sleeps.
130
+ _HEARTBEAT_TIMEOUT = 5.0
131
+
132
+ def stream_health(self) -> tuple[bool, str]:
133
+ """Check audio stream health. Returns (ok, reason).
134
+
135
+ Failure modes detected:
136
+ 1. Stream object gone or closed — device removed, close_stream() bug
137
+ 2. stream.active is False — ALSA underrun, PulseAudio restart
138
+ 3. stream.stopped is True — PortAudio callback abort/error
139
+ 4. Callback heartbeat stale — stream "active" but no callbacks
140
+ (device silently disconnected, PipeWire graph change, driver bug)
141
+ """
142
+ if self._stream is None:
143
+ return False, "stream is None"
144
+ if self._stream.closed:
145
+ return False, "stream closed"
146
+ if not self._stream.active:
147
+ return False, "stream not active"
148
+ if self._stream.stopped:
149
+ return False, "stream stopped"
150
+ if self._last_callback_time > 0:
151
+ stale = time.monotonic() - self._last_callback_time
152
+ if stale > self._HEARTBEAT_TIMEOUT:
153
+ return False, f"no audio callback for {stale:.1f}s"
154
+ return True, ""
155
+
156
+ def has_signal(self) -> bool:
157
+ """Check if the pre-buffer ring contains non-silence audio.
158
+
159
+ Returns False if the ring is all zeros (mic muted, wrong device,
160
+ or stream not delivering real data).
161
+ """
162
+ buf = self._ring.get()
163
+ if len(buf) == 0:
164
+ return False
165
+ # Check if peak amplitude is above a very low floor (~-80 dB).
166
+ # Even "silence" from a working mic has some noise > 1e-4.
167
+ return float(np.max(np.abs(buf))) > 1e-4
168
+
169
+ def reopen_stream(self) -> None:
170
+ """Close and reopen the audio stream (recovery from audio errors)."""
171
+ log.info("Reopening audio stream")
172
+ self.close_stream()
173
+ self._last_callback_time = 0.0
174
+ self.open_stream()
175
+
124
176
  def close_stream(self) -> None:
125
177
  """Stop the always-on audio stream."""
126
178
  if self._stream is not None:
@@ -201,6 +253,8 @@ class AudioRecorder:
201
253
  def _callback(
202
254
  self, indata: np.ndarray, frames: int, time_info: object, status: object
203
255
  ) -> None:
256
+ self._last_callback_time = time.monotonic()
257
+
204
258
  if status:
205
259
  log.warning("Audio stream status: %s", status)
206
260
 
@@ -135,10 +135,16 @@ class StreamingSession:
135
135
  self._pending.wait(timeout=1.0)
136
136
  if self._stop_event.is_set():
137
137
  break
138
- self._transcribe_and_apply()
138
+ try:
139
+ self._transcribe_and_apply()
140
+ except Exception:
141
+ log.exception("Streaming transcribe/apply error (non-fatal)")
139
142
 
140
143
  # Final transcription on stop using the audio snapshot
141
- self._transcribe_and_apply(min_seconds=0.5, final=True)
144
+ try:
145
+ self._transcribe_and_apply(min_seconds=0.5, final=True)
146
+ except Exception:
147
+ log.exception("Final transcribe/apply error")
142
148
  self._final_audio = None # release memory
143
149
 
144
150
  def _transcribe_and_apply(
@@ -257,6 +257,54 @@ def set_error(error: bool) -> None:
257
257
  set_title("voiceio - error" if error else "voiceio - idle")
258
258
 
259
259
 
260
+ def is_alive() -> bool:
261
+ """Check if the tray subprocess is still running (indicator backend only)."""
262
+ if _backend == "indicator":
263
+ return _proc is not None and _proc.poll() is None
264
+ # pystray runs in-process, assume alive if backend is set
265
+ return _backend is not None
266
+
267
+
268
+ def restart(toggle_callback: Callable[[], None] | None = None) -> bool:
269
+ """Restart a dead indicator subprocess. Returns True on success."""
270
+ global _proc, _stdout_thread
271
+
272
+ if _backend != "indicator":
273
+ return False
274
+ if _proc is not None and _proc.poll() is None:
275
+ return True # still alive
276
+
277
+ system_python = _find_system_python()
278
+ if not system_python or _theme_dir is None:
279
+ return False
280
+
281
+ from voiceio.tray._icons import ANIM_INTERVAL_MS
282
+ # Read icon names from existing theme dir
283
+ apps_dir = _theme_dir / "hicolor"
284
+ icon_names: list[str] = []
285
+ for d in apps_dir.rglob("apps"):
286
+ icon_names = sorted(
287
+ p.stem for p in d.glob("*.png")
288
+ )
289
+ break
290
+ if not icon_names:
291
+ return False
292
+
293
+ log.info("Restarting tray indicator subprocess")
294
+ _proc = _start_indicator(
295
+ _theme_dir, icon_names, ANIM_INTERVAL_MS, system_python,
296
+ )
297
+ if toggle_callback is not None:
298
+ _stdout_thread = threading.Thread(
299
+ target=_read_stdout,
300
+ args=(_proc, toggle_callback),
301
+ daemon=True,
302
+ name="tray-stdout",
303
+ )
304
+ _stdout_thread.start()
305
+ return True
306
+
307
+
260
308
  def stop() -> None:
261
309
  """Stop the tray icon and clean up."""
262
310
  global _proc, _backend, _stdout_thread
@@ -274,13 +274,17 @@ class IBusTyper:
274
274
  """IBus preedit for streaming preview, IBus commit for final text.
275
275
 
276
276
  Also copies committed text to clipboard so terminal users (where IBus
277
- doesn't reach) can Ctrl+Shift+V to paste.
277
+ doesn't reach) can Ctrl+Shift+V to paste. When the IBus engine reports
278
+ that no client has focus (e.g. terminal emulators), we auto-paste via
279
+ ydotool/wtype so the user doesn't have to paste manually.
278
280
  """
279
281
 
280
282
  name = "ibus"
281
283
 
282
284
  def __init__(self, platform=None, **kwargs):
283
285
  self._wl_copy = shutil.which("wl-copy")
286
+ self._ydotool = shutil.which("ydotool")
287
+ self._wtype = shutil.which("wtype")
284
288
  self._sock: socket.socket | None = None
285
289
  self._wl_copy_proc: subprocess.Popen | None = None
286
290
 
@@ -331,6 +335,8 @@ class IBusTyper:
331
335
  return
332
336
  self._send(f"commit:{text}")
333
337
  self._copy_to_clipboard(text)
338
+ if not self._engine_has_focus():
339
+ self._simulate_paste()
334
340
 
335
341
  def delete_chars(self, n: int) -> None:
336
342
  pass # Not needed: preedit handles corrections atomically
@@ -346,6 +352,8 @@ class IBusTyper:
346
352
  log.debug("Committing via IBus (%d chars)", len(text))
347
353
  self._send(f"commit:{text}")
348
354
  self._copy_to_clipboard(text)
355
+ if not self._engine_has_focus():
356
+ self._simulate_paste()
349
357
 
350
358
  def clear_preedit(self) -> None:
351
359
  self._send("clear")
@@ -363,6 +371,38 @@ class IBusTyper:
363
371
  self._sock.close()
364
372
  self._sock = None
365
373
 
374
+ def _engine_has_focus(self) -> bool:
375
+ """Ask the IBus engine whether it has input focus in the active window."""
376
+ try:
377
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
378
+ sock.settimeout(0.3)
379
+ sock.bind("") # autobind for receiving reply
380
+ sock.sendto(b"focus?", str(SOCKET_PATH))
381
+ data, _ = sock.recvfrom(64)
382
+ sock.close()
383
+ return data == b"focused"
384
+ except (OSError, socket.timeout):
385
+ return True # assume focused on error (safe default: no extra paste)
386
+
387
+ def _simulate_paste(self) -> None:
388
+ """Simulate Ctrl+V to paste clipboard into non-IBus apps (terminals)."""
389
+ try:
390
+ if self._ydotool:
391
+ # ydotool key scancodes: 29=LCtrl, 47=V
392
+ subprocess.run(
393
+ [self._ydotool, "key", "29:1", "47:1", "47:0", "29:0"],
394
+ capture_output=True, timeout=3,
395
+ )
396
+ elif self._wtype:
397
+ subprocess.run(
398
+ [self._wtype, "-M", "ctrl", "-k", "v", "-m", "ctrl"],
399
+ capture_output=True, timeout=3,
400
+ )
401
+ else:
402
+ log.debug("No paste tool available (ydotool/wtype)")
403
+ except (subprocess.TimeoutExpired, OSError) as e:
404
+ log.debug("Paste simulation failed: %s", e)
405
+
366
406
  def _copy_to_clipboard(self, text: str) -> None:
367
407
  """Copy text to clipboard as backup for non-IBus apps (terminals).
368
408
 
@@ -1 +0,0 @@
1
- __version__ = "0.3.2"
File without changes
File without changes
File without changes