python-voiceio 0.7.0__py3-none-any.whl → 0.7.1__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,13 +1,13 @@
1
- python_voiceio-0.7.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
1
+ python_voiceio-0.7.1.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=2KJZDSMOG7KS82AxYOrZ4ZihYxX0wjfUjDsIZh3L024,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=4o3nOv_Z_NCBr6KTHr1eSGT159ZE98dVBAy5wUOpzAo,64398
4
+ voiceio/app.py,sha256=7FfQzXIyM5GFbGzmPtRamUp4jsIvEj4P5GiA1rPBa3s,69751
5
5
  voiceio/audit.py,sha256=vX3x-YAhmt6EZYRZd0_6wvTquZu5sxGMvH45BhSBnCk,14915
6
6
  voiceio/autocorrect.py,sha256=jGsglEYwAHWk2uWedeEZzyAMW2LWO_8nQGZgrWYy4Dc,26552
7
7
  voiceio/autocorrect_state.py,sha256=BNzdHP5slcOV_-OU4MlaKE7xgVJzrPncsYFK30JWTs8,6858
8
8
  voiceio/backends.py,sha256=-rs1YhPblub4NjFb6qCD7Ih9FqBRXtPuvjVv9bQoQ1w,368
9
9
  voiceio/cli.py,sha256=_HfcWQt2Jf34YfI4W1RavsyqegQy9jrg0nwLe6DvD6E,71639
10
- voiceio/clipboard_read.py,sha256=CqbHSvOztcQ3u2I2EzFm0jdcDVKv56gl0T1YfWxNpmI,3390
10
+ voiceio/clipboard_read.py,sha256=lz75CkRGh7pyvO6xvoUmxJDl-Gwie1xlXQHK70PO84I,4261
11
11
  voiceio/commands.py,sha256=Vhtn8s5G5OcCbRN44YZ-_14Fa85ISXl_3Ix-KqSHs2A,4694
12
12
  voiceio/config.py,sha256=CHDtEigtv-aKPsDFFGkc1AiqmubiFHeU1HaEaPgoIMc,14343
13
13
  voiceio/consent.py,sha256=jtdp1TkTbicUSTCDmV4qo4SdbS4kQpYzy1Kjc_7pJVw,1883
@@ -43,7 +43,7 @@ voiceio/hotkeys/evdev.py,sha256=DHmd2IASBkYnyY5JEGFIoYfZtw3T9Ij6wKclkx-6m94,5109
43
43
  voiceio/hotkeys/pynput_backend.py,sha256=_rLb8p0JakR9T32LJclHVTch49M_mnB3UeUsI_IkfLk,3576
44
44
  voiceio/hotkeys/socket_backend.py,sha256=xLNFa8FvJbL5VLKqMRhKPeaKdhztYwxyVV_VOlG1PkM,3373
45
45
  voiceio/ibus/__init__.py,sha256=12n0pSsb1ZPE1kkscDFmcQjZzovC8zrAnmYbvIuv1AI,284
46
- voiceio/ibus/engine.py,sha256=yTCCHiUPL4-trggSCtIPZ-Wz0bxYoQWK72McypGNmXI,8735
46
+ voiceio/ibus/engine.py,sha256=AUa0SUqo630uZrphnYX0JCsEo4ibN5yMR0P8BN3f-YA,8990
47
47
  voiceio/ibus/pending.py,sha256=T6Y_8oimhabahunp9RogtO-OTA5aw9d9G4T5lH1WAL4,1464
48
48
  voiceio/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
49
  voiceio/models/silero_vad.onnx,sha256=GhU6IvRQnikqlOZ9b5uF6N6yW0mIaCt-F0xlJ52HiOM,2327524
@@ -66,13 +66,13 @@ voiceio/typers/__init__.py,sha256=lFIMEjfPGw8tfYGELhsq0mYM849uKLx_SZhbHqGJrlo,10
66
66
  voiceio/typers/base.py,sha256=VVMgARlpaSVqen-zFUssnbUMYDrWryacRE8MKvlLk-s,1180
67
67
  voiceio/typers/chain.py,sha256=F4QcAMTzYvrxeC-_Er4CbjnqQ1ONYjt7Fp11y8PNpqU,2964
68
68
  voiceio/typers/clipboard.py,sha256=cxeOL_a_P2HeJ-nr-klG94nHjbd9rWu3nxGDXmyfzys,6110
69
- voiceio/typers/ibus.py,sha256=ksYaCXHVrDTYR1R2MMt6H6csd4mtlbaf82DpebOyXbo,18715
69
+ voiceio/typers/ibus.py,sha256=ciaB5OgW5RqgLVdGZqoBhU4q1ttkh7o2H4aAoXSCqbA,18531
70
70
  voiceio/typers/pynput_type.py,sha256=DTtkT59M-EKOAlTNkvcZrRvf_Fq52OpI8S5t_8S-QAY,1887
71
71
  voiceio/typers/wtype.py,sha256=d1wG-HZdYDQxXIysV_Xvk9HixDpaJgl3VEoKbP5vhIs,1786
72
72
  voiceio/typers/xdotool.py,sha256=dh1zhPqUT8ihJahJ7ZKm6PtfYY087UAzADx664DvQOM,1356
73
73
  voiceio/typers/ydotool.py,sha256=dt0W9ot__W8LQG2so8vZLGIw9leK2Q6g3i-Voo0LXE4,5327
74
- python_voiceio-0.7.0.dist-info/METADATA,sha256=hV7gmFjuAjAJpCb9O01qqobjZfk7qxk3Kjow7l5-oaI,15877
75
- python_voiceio-0.7.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
- python_voiceio-0.7.0.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
- python_voiceio-0.7.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
- python_voiceio-0.7.0.dist-info/RECORD,,
74
+ python_voiceio-0.7.1.dist-info/METADATA,sha256=fVpqxHUBfrnn_QwxBAthynoCWysESGlgDLcPK7nfsT8,15877
75
+ python_voiceio-0.7.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
+ python_voiceio-0.7.1.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
+ python_voiceio-0.7.1.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
+ python_voiceio-0.7.1.dist-info/RECORD,,
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.7.0"
1
+ __version__ = "0.7.1"
voiceio/app.py CHANGED
@@ -234,6 +234,11 @@ class VoiceIO:
234
234
  # IBus engine management
235
235
  self._prev_ibus_engine: str | None = None
236
236
  self._engine_proc: subprocess.Popen | None = None
237
+ # Engine lifecycle: consecutive missed pings (one miss on a busy
238
+ # system is not a zombie) and a guard against concurrent spawns
239
+ # (health loop vs record-start).
240
+ self._engine_ping_fails = 0
241
+ self._engine_spawn_lock = threading.Lock()
237
242
  self._shutdown = threading.Event()
238
243
  # IBus input-source ownership. We claim the GNOME input source ONLY
239
244
  # while a recording is live (RECORDING/FINALIZING) and restore whatever
@@ -366,6 +371,15 @@ class VoiceIO:
366
371
 
367
372
  self._state = _State.RECORDING
368
373
  self._ibus_session_fallback = False
374
+ if self._typer.name == "ibus" and self._engine_proc is None:
375
+ # Engine was reaped while dormant (normal — see _check_health).
376
+ # Respawn in the background; the engine's pending buffer holds
377
+ # preedits until the instance is ready, and with state=RECORDING
378
+ # the spawn leaves the voiceio source active instead of yanking
379
+ # it back to dormant mid-recording.
380
+ threading.Thread(
381
+ target=self._respawn_engine_for_recording, daemon=True,
382
+ ).start()
369
383
  self._activate_ibus()
370
384
  self._corrections.load() # hot-reload corrections on each recording
371
385
  self._refresh_hotwords() # hot-reload vocabulary (mtime-cached)
@@ -898,8 +912,29 @@ class VoiceIO:
898
912
 
899
913
  # ── IBus engine lifecycle ───────────────────────────────────────────
900
914
 
915
+ def _respawn_engine_for_recording(self) -> None:
916
+ """Background lazy respawn at record start. Never raises."""
917
+ try:
918
+ self._ensure_ibus_engine()
919
+ except Exception:
920
+ log.exception("IBus engine respawn at record start failed")
921
+
901
922
  def _ensure_ibus_engine(self) -> None:
902
- """Start the VoiceIO IBus engine and activate it."""
923
+ """Start the VoiceIO IBus engine and activate it.
924
+
925
+ Single-flight: a concurrent spawn (health loop vs record start)
926
+ is skipped rather than queued — two dances would fight over the
927
+ input source.
928
+ """
929
+ if not self._engine_spawn_lock.acquire(blocking=False):
930
+ log.debug("IBus engine spawn already in progress, skipping")
931
+ return
932
+ try:
933
+ self._spawn_ibus_engine()
934
+ finally:
935
+ self._engine_spawn_lock.release()
936
+
937
+ def _spawn_ibus_engine(self) -> None:
903
938
  from voiceio.ibus import READY_PATH, SOCKET_PATH
904
939
  from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
905
940
 
@@ -976,9 +1011,16 @@ class VoiceIO:
976
1011
  except subprocess.TimeoutExpired:
977
1012
  activate_proc.kill()
978
1013
 
979
- # Return to dormancy on the user's real source (not hardcoded 0).
980
- self._set_gnome_input_source_index(dormant_idx if dormant_idx is not None else 0)
981
- log.info("VoiceIO IBus engine ready (dormant until recording)")
1014
+ # Return to dormancy on the user's real source (not hardcoded 0)
1015
+ # unless a recording claimed the source while we were spawning:
1016
+ # yanking it away now would kill the live preedit for that whole
1017
+ # recording (the "underlined text never appears" symptom).
1018
+ if self._state == _State.IDLE:
1019
+ self._set_gnome_input_source_index(dormant_idx if dormant_idx is not None else 0)
1020
+ self._release_ibus_engine()
1021
+ log.info("VoiceIO IBus engine ready (dormant until recording)")
1022
+ else:
1023
+ log.info("VoiceIO IBus engine ready (recording live — staying active)")
982
1024
 
983
1025
  def _ping_ibus_engine(self) -> bool:
984
1026
  """Check if the IBus engine's socket listener is alive.
@@ -991,7 +1033,7 @@ class VoiceIO:
991
1033
  from voiceio.ibus import SOCKET_PATH
992
1034
  try:
993
1035
  sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
994
- sock.settimeout(1.0)
1036
+ sock.settimeout(2.0)
995
1037
  sock.bind("")
996
1038
  sock.sendto(b"ping", str(SOCKET_PATH))
997
1039
  data, _ = sock.recvfrom(64)
@@ -1091,6 +1133,49 @@ class VoiceIO:
1091
1133
  self._set_gnome_input_source_index(idx if idx is not None else 0)
1092
1134
  self._prev_input_source_index = None
1093
1135
  self._voiceio_source_index = None
1136
+ self._release_ibus_engine()
1137
+
1138
+ def _ibus_engine_fallback(self) -> str:
1139
+ """The user's non-voiceio ibus engine name, for releasing the engine."""
1140
+ if self._prev_ibus_engine and self._prev_ibus_engine != "voiceio":
1141
+ return self._prev_ibus_engine
1142
+ try:
1143
+ result = subprocess.run(
1144
+ ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
1145
+ capture_output=True, text=True, timeout=3,
1146
+ )
1147
+ import ast
1148
+ for kind, name in ast.literal_eval(result.stdout.strip()):
1149
+ if kind == "xkb":
1150
+ return f"xkb:{name}::eng"
1151
+ except Exception:
1152
+ pass
1153
+ return "xkb:us::eng"
1154
+
1155
+ def _release_ibus_engine(self) -> None:
1156
+ """Point ibus's GLOBAL engine back at the user's layout when dormant.
1157
+
1158
+ Restoring the GNOME input source is not enough: the spawn dance sets
1159
+ the global engine directly (`ibus engine voiceio`), and a gsettings
1160
+ write that doesn't change the value never makes GNOME push an engine
1161
+ change. The stale global engine then routes EVERY input context
1162
+ through voiceio while "dormant" — constant focus churn that discards
1163
+ any visible preedit, surviving daemon restarts.
1164
+ """
1165
+ from voiceio.typers.ibus import _ibus_env
1166
+ try:
1167
+ # Unconditional: querying first races the (async) activation —
1168
+ # a stale reading made the release skip itself and left voiceio
1169
+ # active. Going dormant means the user's engine should be the
1170
+ # global one; setting it is idempotent.
1171
+ fallback = self._ibus_engine_fallback()
1172
+ subprocess.run(
1173
+ ["ibus", "engine", fallback],
1174
+ capture_output=True, timeout=3, env=_ibus_env(),
1175
+ )
1176
+ log.debug("Released ibus global engine to %s", fallback)
1177
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1178
+ pass
1094
1179
 
1095
1180
  def _switch_gnome_input_source(self, engine_name: str) -> None:
1096
1181
  if not self.platform.is_gnome:
@@ -1124,6 +1209,16 @@ class VoiceIO:
1124
1209
  "current", str(i)],
1125
1210
  capture_output=True, timeout=3,
1126
1211
  )
1212
+ # The gsettings write alone is a no-op on newer GNOME
1213
+ # (the 'current' key is legacy) — activation only ever
1214
+ # worked while the global engine was accidentally stuck
1215
+ # on voiceio. Claim it explicitly, mirroring
1216
+ # _release_ibus_engine() on deactivation.
1217
+ from voiceio.typers.ibus import _ibus_env
1218
+ subprocess.run(
1219
+ ["ibus", "engine", engine_name],
1220
+ capture_output=True, timeout=3, env=_ibus_env(),
1221
+ )
1127
1222
  log.info("Switched GNOME input source to index %d (%s)", i, engine_name)
1128
1223
  time.sleep(0.5)
1129
1224
  return
@@ -1216,17 +1311,13 @@ class VoiceIO:
1216
1311
  # of ibus) — the preferred backend may be available now.
1217
1312
  self._try_upgrade_typer(reason="resume")
1218
1313
 
1219
- # IBus: restart the engine only if its process died. Never re-force the
1220
- # active input source on resume activation happens on record-start.
1314
+ # IBus: an engine that died during sleep is respawned lazily at the
1315
+ # next record start (same as health-loop reaping respawning here
1316
+ # would grab the input source right as the user unlocks).
1221
1317
  if self._typer.name == "ibus" and self._engine_proc is not None:
1222
1318
  if self._engine_proc.poll() is not None:
1223
- # Engine process died during sleep
1319
+ log.debug("Resume: IBus engine died during sleep — will respawn at next recording")
1224
1320
  self._engine_proc = None
1225
- try:
1226
- self._ensure_ibus_engine()
1227
- log.info("Resume: IBus engine restarted")
1228
- except Exception:
1229
- log.exception("Resume: IBus engine restart failed")
1230
1321
 
1231
1322
  # Tray: restart if subprocess died
1232
1323
  if self.cfg.tray.enabled and not tray.is_alive():
@@ -1295,29 +1386,45 @@ class VoiceIO:
1295
1386
  if self._state == _State.IDLE:
1296
1387
  self._health_typer_upkeep()
1297
1388
 
1298
- # Check IBus engine liveness. When IDLE we ONLY restart a dead/zombie
1299
- # engine process we never re-force the active input source, so we
1300
- # don't fight users running a real IME. When a recording is live we
1301
- # watch for the user switching source away from us and fall back.
1389
+ # Check IBus engine liveness. A dormant engine dying is NORMAL:
1390
+ # ibus-daemon reaps deactivated engine processes (SIGTERM ~10s after
1391
+ # the input source switches away). Eagerly respawning here created a
1392
+ # spawn→activate→dormant→reap loop that grabbed the user's input
1393
+ # source every cycle — the focus churn wiped any visible preedit
1394
+ # (flickering underline). Respawn lazily at the next record start.
1302
1395
  if self._typer.name == "ibus" and self._engine_proc is not None:
1303
1396
  if self._engine_proc.poll() is not None:
1304
- log.warning("IBus engine process died (rc=%d), restarting",
1305
- self._engine_proc.returncode)
1397
+ rc = self._engine_proc.returncode
1306
1398
  self._engine_proc = None
1307
- try:
1308
- self._ensure_ibus_engine()
1309
- log.info("IBus engine recovered")
1310
- except Exception:
1311
- log.exception("IBus engine recovery failed")
1399
+ self._engine_ping_fails = 0
1400
+ if self._state == _State.IDLE:
1401
+ log.debug(
1402
+ "IBus engine exited while dormant (rc=%s) — normal "
1403
+ "reaping, will respawn at next recording", rc,
1404
+ )
1405
+ else:
1406
+ log.warning("IBus engine died mid-recording (rc=%s), restarting", rc)
1407
+ try:
1408
+ self._ensure_ibus_engine()
1409
+ log.info("IBus engine recovered")
1410
+ except Exception:
1411
+ log.exception("IBus engine recovery failed")
1312
1412
  elif self._state == _State.IDLE:
1313
1413
  # Engine process alive — check if its socket listener is
1314
1414
  # actually responding. A zombie engine (process alive but
1315
1415
  # GLib loop stuck / socket thread dead) silently drops all
1316
- # preedit and commit messages, causing the "transcription
1317
- # works but no text appears" symptom. Restart it, but do NOT
1318
- # touch the active input source.
1416
+ # preedit and commit messages. One missed ping on a loaded
1417
+ # system is not a zombie require two consecutive misses.
1319
1418
  if not self._ping_ibus_engine():
1419
+ self._engine_ping_fails += 1
1420
+ if self._engine_ping_fails < 2:
1421
+ log.debug(
1422
+ "IBus engine missed a ping (%d/2) — busy system?",
1423
+ self._engine_ping_fails,
1424
+ )
1425
+ return
1320
1426
  log.warning("IBus engine not responding to ping, restarting")
1427
+ self._engine_ping_fails = 0
1321
1428
  self._engine_proc.kill()
1322
1429
  try:
1323
1430
  self._engine_proc.wait(timeout=3)
@@ -1329,6 +1436,8 @@ class VoiceIO:
1329
1436
  log.info("IBus engine recovered (was zombie)")
1330
1437
  except Exception:
1331
1438
  log.exception("IBus engine recovery failed")
1439
+ else:
1440
+ self._engine_ping_fails = 0
1332
1441
  else:
1333
1442
  # RECORDING / FINALIZING: don't re-force the source; if the
1334
1443
  # user switched away from voiceio, fall back to clipboard.
voiceio/clipboard_read.py CHANGED
@@ -70,10 +70,19 @@ def read_text() -> str | None:
70
70
 
71
71
 
72
72
  def _copy_via(cmd: list[str], text: str) -> bool:
73
- """Pipe text into a copy command. Returns True on success."""
73
+ """Pipe text into a copy command. Returns True on success.
74
+
75
+ stdout/stderr go to DEVNULL, NOT pipes: xclip daemonizes (forks a child
76
+ that owns the selection and inherits the fds), so captured pipes never
77
+ reach EOF and run() would block for the full timeout, then report a
78
+ bogus failure — which used to trigger the wl-copy fallback and its
79
+ focus steal on every single copy.
80
+ """
74
81
  try:
75
82
  result = subprocess.run(
76
- cmd, input=text.encode(), capture_output=True, timeout=_TIMEOUT,
83
+ cmd, input=text.encode(),
84
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
85
+ timeout=_TIMEOUT,
77
86
  )
78
87
  return result.returncode == 0
79
88
  except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
@@ -103,6 +112,13 @@ def copy_text(text: str) -> bool:
103
112
  return _copy_via(["pbcopy"], text)
104
113
 
105
114
  if p.is_wayland:
115
+ # Prefer xclip via the XWayland clipboard bridge: wl-copy can
116
+ # momentarily steal keyboard focus on GNOME (fallback surface),
117
+ # and a focus-out discards any visible IBus preedit — streaming
118
+ # milestone copies were wiping the underlined preview one frame
119
+ # after it painted. X11 selections never touch Wayland focus.
120
+ if shutil.which("xclip") and _copy_via(["xclip", "-selection", "clipboard"], text):
121
+ return True
106
122
  if shutil.which("wl-copy"):
107
123
  return _copy_via(["wl-copy", "--"], text)
108
124
  else:
voiceio/ibus/engine.py CHANGED
@@ -43,9 +43,13 @@ class VoiceIOEngine(IBus.Engine):
43
43
  log.info("VoiceIOEngine instance created (path=%s)", kwargs.get("object_path"))
44
44
 
45
45
  def do_focus_in(self):
46
+ # Focus transitions also discard any visible preedit (IBus default
47
+ # mode), so log them — a flickering preedit is usually focus churn.
48
+ log.debug("focus_in")
46
49
  self._focused = True
47
50
 
48
51
  def do_focus_out(self):
52
+ log.debug("focus_out (preedit discarded by client if visible)")
49
53
  self._focused = False
50
54
 
51
55
  def do_process_key_event(self, keyval, keycode, state):
voiceio/typers/ibus.py CHANGED
@@ -476,25 +476,17 @@ class IBusTyper:
476
476
  def _copy_to_clipboard(self, text: str) -> None:
477
477
  """Copy text to clipboard as backup for non-IBus apps (terminals).
478
478
 
479
- Fire-and-forget. wl-copy stays alive to serve paste requests.
480
- Kill previous instance to avoid accumulating processes.
479
+ Routed through clipboard_read.copy_text (xclip / XWayland bridge
480
+ first): wl-copy momentarily steals keyboard focus on GNOME, and the
481
+ focus-out discards any visible preedit.
481
482
  """
482
- if not self._wl_copy:
483
- return
484
- # Kill previous wl-copy (it's replaced by the new one)
483
+ # Kill any wl-copy left over from the pre-xclip implementation.
485
484
  if self._wl_copy_proc is not None:
486
485
  try:
487
486
  self._wl_copy_proc.kill()
488
487
  except OSError:
489
488
  pass
490
- try:
491
- proc = subprocess.Popen(
492
- ["wl-copy"],
493
- stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
494
- stderr=subprocess.DEVNULL,
495
- )
496
- proc.stdin.write(text.encode())
497
- proc.stdin.close()
498
- self._wl_copy_proc = proc
499
- except OSError as e:
500
- log.debug("wl-copy failed to start: %s", e)
489
+ self._wl_copy_proc = None
490
+ from voiceio import clipboard_read
491
+ if not clipboard_read.copy_text(text):
492
+ log.debug("Clipboard copy failed (no working tool)")