python-voiceio 0.8.0__py3-none-any.whl → 0.8.2__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.8.0
3
+ Version: 0.8.2
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,7 +1,7 @@
1
- python_voiceio-0.8.0.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
- voiceio/__init__.py,sha256=iPlYCcIzuzW7T2HKDkmYlMkRI51dBLfNRxPPiWrfw9U,22
1
+ python_voiceio-0.8.2.dist-info/licenses/LICENSE,sha256=Gz61o8jFTAvZUZyB3nWDB3DQQVuipjfPkVu9W8hBHM0,1072
2
+ voiceio/__init__.py,sha256=B7GiO0rd49YwtLYjvPg4lmCZEDlMTonslQKdSImaMJk,22
3
3
  voiceio/__main__.py,sha256=xT5QCGGreYMisHO7Lh_Y-xAQ2TOzG2D7npKknGNcWSY,53
4
- voiceio/app.py,sha256=7FfQzXIyM5GFbGzmPtRamUp4jsIvEj4P5GiA1rPBa3s,69751
4
+ voiceio/app.py,sha256=vO5Sk12CxT2aFtt14XP1Vyen_leUQcqYftswURjN58U,71360
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
@@ -27,23 +27,23 @@ voiceio/postprocess.py,sha256=9grkq3I6XgiQV6ZQbIw2_fOUe57tzC7GBPvLXqhhdTs,2979
27
27
  voiceio/prompt.py,sha256=9iza8KoQxvO_U9NPMJ8Ep7j-zkIs9nW8N5sacCRpsEk,2904
28
28
  voiceio/recorder.py,sha256=p1r0mH_plBD3BHqBbIGV_m2N4Mj5RAF7LfMu8u6mk5I,12813
29
29
  voiceio/retention.py,sha256=i_VWPCyykT4kQ1uKilTplpiIrACTYljn09BU1vL71A0,6019
30
- voiceio/service.py,sha256=jqX1opZyQh_dOJMK4bVf8Aoei0zOuuk1Ogta9BXTWGY,12106
30
+ voiceio/service.py,sha256=e3j5OxhuwEqcgXlmQ9AhgdNrrZPwCNfzj-BmABuBYRw,12145
31
31
  voiceio/snapshots.py,sha256=bm-lzIeYucDUDlPBZiFiWd-jyB2I6Ft1jRANZCO6go4,2351
32
32
  voiceio/streaming.py,sha256=mXkQEEMGMbABSRNG2H7MYObKUdCwfqMvNIZISGZ5uic,25360
33
- voiceio/transcriber.py,sha256=2ev_hwdusve7ZZqht9A9wtjjT72rcr7SuhmSIW8aClk,10721
33
+ voiceio/transcriber.py,sha256=NWYMZpIhYccUBKzcFyMzjH1MTMNv4bG9op5Qw5reOhI,11608
34
34
  voiceio/vad.py,sha256=72_ICk4jSuSPYvTHdHYtHzY8W5uaGEOz5FTdqkolJi8,5327
35
35
  voiceio/vocabulary.py,sha256=eGV7QZs3tN6Cw5YpyoZsVEk2M5fJvie1n2XFi9CD4hw,6321
36
36
  voiceio/wizard.py,sha256=AwjwrocrQp0DPSOMZ0m6z6G3hlo9VPceEwPzR9-xXe0,80227
37
37
  voiceio/wordfreq.py,sha256=2UMjW1xIFqo5EchXzsXPizUhRb9N18CVgjnXso1wsgw,2382
38
- voiceio/worker.py,sha256=M6e4s6P0-4UGQzw0j8Mq68nypvwZleOSwuRhjuOqlVE,2563
38
+ voiceio/worker.py,sha256=Ec_OChl6CCX1XHdNYdmx_4l4_sIRexyxih64-PBNhjs,3233
39
39
  voiceio/hotkeys/__init__.py,sha256=rGXSGZLD2mS_Ep2HfVdRdkUKNr4xagySlL1FM00cYgk,735
40
40
  voiceio/hotkeys/base.py,sha256=L3iBh368sjWpt64JZpocH9DibYEZ0V16-rYflLYbpZY,699
41
41
  voiceio/hotkeys/chain.py,sha256=8pvPhipmxWwmGG9DRXy8dlEKTPFILxNzYO-adcvw5Ps,2800
42
42
  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
- voiceio/ibus/__init__.py,sha256=12n0pSsb1ZPE1kkscDFmcQjZzovC8zrAnmYbvIuv1AI,284
46
- voiceio/ibus/engine.py,sha256=AUa0SUqo630uZrphnYX0JCsEo4ibN5yMR0P8BN3f-YA,8990
45
+ voiceio/ibus/__init__.py,sha256=WZgQTPNnQZmmFurw3XPQEfV49nehmKoDI0wTg-WId1E,1120
46
+ voiceio/ibus/engine.py,sha256=S_vepGH_6ZNZL1UBbgZZPhWbTkjrF9osVn5_Nf5utqI,9602
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
@@ -53,7 +53,7 @@ voiceio/sounds/start.wav,sha256=NrkQXa81zCDyDMkLMzZYTz6ldUvk2LNx4JWSFbFWwBk,1680
53
53
  voiceio/sounds/stop.wav,sha256=mQXAdEN6PEX17I-GSooygr3zFZMCW64bFm_v9IChqZ0,16802
54
54
  voiceio/tray/__init__.py,sha256=gywMDmgAlVYft7DiilQaBzWIL8f2_cO7XSRtVJ4k880,10775
55
55
  voiceio/tray/_icons.py,sha256=RJ6BUZ5Winn4e6mrrObDqftqTuxL4jJrZa9sDjCAcPo,4439
56
- voiceio/tray/_indicator.py,sha256=gxQSZJ6g_Nv_UrOgQSN4mjGXpTY9O4BOLGrMCdugooY,5858
56
+ voiceio/tray/_indicator.py,sha256=Zq_5kClEKHCJ80YZj-uMT0Vh-NbB9jeqEftHUqQU4Tw,7865
57
57
  voiceio/tray/_pystray.py,sha256=99DR5-5ZmVtzLjEPWL5W430akQ5LfJk23vGag_IXXAk,3451
58
58
  voiceio/tts/__init__.py,sha256=96OGy6MZI1a-tLZLKnFRBv1dtRFtRSPFslXqIVtqXSw,305
59
59
  voiceio/tts/base.py,sha256=WI7RTkgceXNbV5R_gixShdh9YG8K4sFD-_mf8TDoyeM,685
@@ -71,8 +71,8 @@ voiceio/typers/pynput_type.py,sha256=DTtkT59M-EKOAlTNkvcZrRvf_Fq52OpI8S5t_8S-QAY
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.8.0.dist-info/METADATA,sha256=bRxsz8ixj-sSuzshHXT9Ca-_08P7fztM4jRG-ewujrA,15877
75
- python_voiceio-0.8.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
- python_voiceio-0.8.0.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
- python_voiceio-0.8.0.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
- python_voiceio-0.8.0.dist-info/RECORD,,
74
+ python_voiceio-0.8.2.dist-info/METADATA,sha256=tHzCPzNpT-x1-89kZF547QCd-Qo8_XqiL7NIsia6gNw,15877
75
+ python_voiceio-0.8.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
76
+ python_voiceio-0.8.2.dist-info/entry_points.txt,sha256=U64fA65zxzyLoC8bgbn2ztQVWHLsc0o0H0qCk1J9DMc,218
77
+ python_voiceio-0.8.2.dist-info/top_level.txt,sha256=piwtn309lD6uexQyXdZ-efAVBJF9y6Wfr48Z-8zkNhg,8
78
+ python_voiceio-0.8.2.dist-info/RECORD,,
voiceio/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.8.0"
1
+ __version__ = "0.8.2"
voiceio/app.py CHANGED
@@ -34,6 +34,10 @@ _DEBOUNCE_SECS = 0.8
34
34
  # IBus input source configuration.
35
35
  _GRAPHICAL_ENV_VARS = (
36
36
  "DISPLAY",
37
+ # Without the X auth cookie, X11 tools (xclip via the XWayland bridge)
38
+ # fail silently inside the daemon and clipboard copies fall back to
39
+ # wl-copy — whose focus steal discards any visible IBus preedit.
40
+ "XAUTHORITY",
37
41
  "WAYLAND_DISPLAY",
38
42
  "XDG_SESSION_TYPE",
39
43
  "XDG_CURRENT_DESKTOP",
@@ -893,7 +897,8 @@ class VoiceIO:
893
897
  return False
894
898
  if new_typer.name == "ibus" and self._engine_proc is None:
895
899
  try:
896
- self._ensure_ibus_engine() # subprocess ops, outside the lock
900
+ # subprocess ops, outside the lock; IDLE → never activate
901
+ self._ensure_ibus_engine(activate=False)
897
902
  except Exception:
898
903
  log.exception("Upgrade (%s): failed to start IBus engine", reason)
899
904
  return True
@@ -919,8 +924,12 @@ class VoiceIO:
919
924
  except Exception:
920
925
  log.exception("IBus engine respawn at record start failed")
921
926
 
922
- def _ensure_ibus_engine(self) -> None:
923
- """Start the VoiceIO IBus engine and activate it.
927
+ def _ensure_ibus_engine(self, activate: bool = True) -> None:
928
+ """Start the VoiceIO IBus engine, optionally pre-creating its instance.
929
+
930
+ ``activate=False`` spawns and registers the engine process without
931
+ ever touching the input source — the only safe mode while IDLE.
932
+ Claiming the source (``activate=True``) is reserved for record start.
924
933
 
925
934
  Single-flight: a concurrent spawn (health loop vs record start)
926
935
  is skipped rather than queued — two dances would fight over the
@@ -930,11 +939,11 @@ class VoiceIO:
930
939
  log.debug("IBus engine spawn already in progress, skipping")
931
940
  return
932
941
  try:
933
- self._spawn_ibus_engine()
942
+ self._spawn_ibus_engine(activate)
934
943
  finally:
935
944
  self._engine_spawn_lock.release()
936
945
 
937
- def _spawn_ibus_engine(self) -> None:
946
+ def _spawn_ibus_engine(self, activate: bool = True) -> None:
938
947
  from voiceio.ibus import READY_PATH, SOCKET_PATH
939
948
  from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
940
949
 
@@ -984,6 +993,16 @@ class VoiceIO:
984
993
  log.warning("IBus engine started but socket not found, commands may fail")
985
994
  return
986
995
 
996
+ if not activate:
997
+ # Registered-but-dormant mode (health loop, daemon start): the
998
+ # factory now owns the component name, so ibus-daemon won't
999
+ # exec-spawn a duplicate engine, and an accidental switch onto
1000
+ # the voiceio source lands on a live pass-through engine. The
1001
+ # engine INSTANCE is created on first activation (record start,
1002
+ # or GNOME switching sources) — no input-source claim needed.
1003
+ log.info("VoiceIO IBus engine ready (registered, dormant)")
1004
+ return
1005
+
987
1006
  # Remember the source the user is on now so we can return the engine
988
1007
  # to dormancy without hardcoding index 0 (which may not be the user's
989
1008
  # keyboard). Phase 2 briefly activates voiceio to spawn the instance.
@@ -1025,22 +1044,12 @@ class VoiceIO:
1025
1044
  def _ping_ibus_engine(self) -> bool:
1026
1045
  """Check if the IBus engine's socket listener is alive.
1027
1046
 
1028
- Returns True if the engine responds to a ping within 1 second.
1029
1047
  A False result means the engine process is alive but its socket
1030
- listener / GLib loop is dead (zombie engine).
1048
+ listener is dead or starved (zombie engine) — e.g. a duplicate
1049
+ engine process stole the socket path.
1031
1050
  """
1032
- import socket as _socket
1033
- from voiceio.ibus import SOCKET_PATH
1034
- try:
1035
- sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
1036
- sock.settimeout(2.0)
1037
- sock.bind("")
1038
- sock.sendto(b"ping", str(SOCKET_PATH))
1039
- data, _ = sock.recvfrom(64)
1040
- sock.close()
1041
- return data == b"pong"
1042
- except (OSError, _socket.timeout):
1043
- return False
1051
+ from voiceio.ibus import ping_engine
1052
+ return ping_engine(timeout=2.0)
1044
1053
 
1045
1054
  def _detect_input_source_hijack(self) -> None:
1046
1055
  """If the user switched input source away from voiceio mid-recording,
@@ -1084,7 +1093,7 @@ class VoiceIO:
1084
1093
  try:
1085
1094
  typer = typer_chain.select(self.platform, self.cfg.output.method)
1086
1095
  if typer.name == "ibus":
1087
- self._ensure_ibus_engine()
1096
+ self._ensure_ibus_engine(activate=False)
1088
1097
  return typer
1089
1098
  except RuntimeError:
1090
1099
  pass
@@ -1388,20 +1397,30 @@ class VoiceIO:
1388
1397
 
1389
1398
  # Check IBus engine liveness. A dormant engine dying is NORMAL:
1390
1399
  # 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.
1400
+ # the input source switches away). Respawn it REGISTERED BUT NEVER
1401
+ # ACTIVATED (activate=False): while our factory owns the component
1402
+ # name, ibus-daemon won't exec-spawn a duplicate engine (a duplicate
1403
+ # rebinds the command socket and silently starves the healthy one),
1404
+ # and an accidental switch onto the voiceio source (Super+Space,
1405
+ # GNOME's per-window source memory) lands on a live pass-through
1406
+ # engine instead of a seconds-long cold spawn that eats keystrokes.
1407
+ # Activation — claiming the input source — happens ONLY at record
1408
+ # start: idle activation was the focus churn that wiped visible
1409
+ # preedits and stole the source mid-typing.
1395
1410
  if self._typer.name == "ibus" and self._engine_proc is not None:
1396
1411
  if self._engine_proc.poll() is not None:
1397
1412
  rc = self._engine_proc.returncode
1398
1413
  self._engine_proc = None
1399
1414
  self._engine_ping_fails = 0
1400
1415
  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,
1416
+ log.info(
1417
+ "IBus engine exited while dormant (rc=%s) — "
1418
+ "respawning registered, without activation", rc,
1404
1419
  )
1420
+ try:
1421
+ self._ensure_ibus_engine(activate=False)
1422
+ except Exception:
1423
+ log.exception("IBus engine respawn failed")
1405
1424
  else:
1406
1425
  log.warning("IBus engine died mid-recording (rc=%s), restarting", rc)
1407
1426
  try:
@@ -1412,9 +1431,9 @@ class VoiceIO:
1412
1431
  elif self._state == _State.IDLE:
1413
1432
  # Engine process alive — check if its socket listener is
1414
1433
  # actually responding. A zombie engine (process alive but
1415
- # GLib loop stuck / socket thread dead) silently drops all
1416
- # preedit and commit messages. One missed ping on a loaded
1417
- # system is not a zombie — require two consecutive misses.
1434
+ # socket stolen by a duplicate / listener dead) silently
1435
+ # drops all preedit and commit messages. One missed ping on
1436
+ # a loaded system is not a zombie — require two consecutive.
1418
1437
  if not self._ping_ibus_engine():
1419
1438
  self._engine_ping_fails += 1
1420
1439
  if self._engine_ping_fails < 2:
@@ -1432,7 +1451,7 @@ class VoiceIO:
1432
1451
  pass
1433
1452
  self._engine_proc = None
1434
1453
  try:
1435
- self._ensure_ibus_engine()
1454
+ self._ensure_ibus_engine(activate=False)
1436
1455
  log.info("IBus engine recovered (was zombie)")
1437
1456
  except Exception:
1438
1457
  log.exception("IBus engine recovery failed")
@@ -1510,7 +1529,9 @@ class VoiceIO:
1510
1529
  self._typer.ensure_installed()
1511
1530
  except Exception:
1512
1531
  log.exception("IBus ensure_installed failed (continuing)")
1513
- self._ensure_ibus_engine()
1532
+ # Register the engine but never claim the input source at boot —
1533
+ # the instance is created on first activation (record start).
1534
+ self._ensure_ibus_engine(activate=False)
1514
1535
  else:
1515
1536
  # IBus daemon may not be ready at startup (race with graphical
1516
1537
  # session). If IBus is in the preferred chain but wasn't selected,
voiceio/ibus/__init__.py CHANGED
@@ -6,3 +6,28 @@ from pathlib import Path
6
6
 
7
7
  SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
8
8
  READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
9
+
10
+
11
+ def ping_engine(timeout: float = 2.0) -> bool:
12
+ """True if a live engine answers a ping on SOCKET_PATH.
13
+
14
+ Used by the daemon's health watchdog, and by the engine itself at
15
+ startup to refuse to double-run: a second engine binding the socket
16
+ path would silently starve the healthy instance (its listener stays
17
+ blocked on a socket inode that no longer receives anything).
18
+ """
19
+ import socket
20
+
21
+ if not SOCKET_PATH.exists():
22
+ return False
23
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
24
+ try:
25
+ sock.settimeout(timeout)
26
+ sock.bind("") # Linux autobind: gives us an address for the reply
27
+ sock.sendto(b"ping", str(SOCKET_PATH))
28
+ data, _ = sock.recvfrom(64)
29
+ return data == b"pong"
30
+ except OSError:
31
+ return False
32
+ finally:
33
+ sock.close()
voiceio/ibus/engine.py CHANGED
@@ -24,7 +24,7 @@ import gi
24
24
  gi.require_version("IBus", "1.0")
25
25
  from gi.repository import GLib, GObject, IBus
26
26
 
27
- from voiceio.ibus import READY_PATH, SOCKET_PATH
27
+ from voiceio.ibus import READY_PATH, SOCKET_PATH, ping_engine
28
28
  from voiceio.ibus.pending import PendingBuffer
29
29
 
30
30
  log = logging.getLogger(__name__)
@@ -146,6 +146,7 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
146
146
  except socket.timeout:
147
147
  continue
148
148
  except OSError:
149
+ log.exception("Socket listener error — listener exiting")
149
150
  break
150
151
 
151
152
  msg = data.decode("utf-8", errors="replace")
@@ -229,6 +230,15 @@ def main() -> None:
229
230
  ],
230
231
  )
231
232
 
233
+ # Refuse to double-run. ibus-daemon can exec-spawn its own copy of this
234
+ # engine (component XML) when something activates the voiceio source —
235
+ # e.g. GNOME's per-window source memory. Binding SOCKET_PATH here would
236
+ # steal it from the healthy daemon-spawned engine and silently starve it
237
+ # (alive, but never sees another command or ping → zombie recovery).
238
+ if ping_engine(timeout=0.5):
239
+ log.info("Healthy VoiceIO engine already serving %s — exiting duplicate", SOCKET_PATH)
240
+ return
241
+
232
242
  IBus.init()
233
243
  bus = IBus.Bus()
234
244
 
voiceio/service.py CHANGED
@@ -61,6 +61,7 @@ PartOf=graphical-session.target
61
61
  [Service]
62
62
  Type=simple
63
63
  ExecStart={bin_path}
64
+ Nice=10
64
65
  Restart=on-failure
65
66
  RestartSec=3
66
67
  Environment=PYTHONUNBUFFERED=1
@@ -80,6 +81,8 @@ Documentation=https://github.com/Hugo0/voiceio
80
81
  [Service]
81
82
  Type=oneshot
82
83
  ExecStart={bin_path} correct --auto --batch
84
+ Nice=15
85
+ IOSchedulingClass=idle
83
86
  Environment=PYTHONUNBUFFERED=1
84
87
  """
85
88
 
voiceio/transcriber.py CHANGED
@@ -29,6 +29,12 @@ class TranscriptionError(RuntimeError):
29
29
  # never kills a long dictation mid-decode.
30
30
  TIMEOUT_PER_SECOND = 1.5
31
31
  MAX_RESTARTS = 3
32
+ # Ceiling on the worker's model-load handshake. A cached load is ~4s (worst
33
+ # observed: 53s), so this only trips on a genuinely stuck start — but it must
34
+ # also leave room for a cold first-run download. Unbounded, a start that hangs
35
+ # (blackholed network, see worker._load_model) holds the transcribe lock
36
+ # forever and every later dictation silently returns "".
37
+ WORKER_START_TIMEOUT = 300
32
38
  # A crash-free stretch this long means earlier crashes were transient (suspend,
33
39
  # device churn) rather than a fatal loop — reset the restart budget so three
34
40
  # unrelated crashes spread over weeks don't permanently kill the daemon.
@@ -118,9 +124,13 @@ class Transcriber:
118
124
  )
119
125
 
120
126
  t0 = time.monotonic()
121
- ready = self._proc.stdout.readline().strip()
122
- if ready != "READY":
123
- raise RuntimeError(f"Worker failed to start: {ready}")
127
+ ready = self._read_with_timeout(WORKER_START_TIMEOUT)
128
+ if ready is None or ready.strip() != "READY":
129
+ self._kill_worker()
130
+ detail = "timed out" if ready is None else f"said {ready.strip()!r}"
131
+ raise TranscriptionError(
132
+ f"worker failed to start ({detail} after {time.monotonic() - t0:.0f}s)"
133
+ )
124
134
  elapsed = time.monotonic() - t0
125
135
  log.info("Model ready (%.1fs)", elapsed)
126
136
 
@@ -199,9 +209,12 @@ class Transcriber:
199
209
  result_line = self._read_with_timeout(timeout)
200
210
  if result_line is None:
201
211
  self.last_segments = []
202
- log.warning("Transcription timed out after %.0fs, restarting worker", timeout)
212
+ log.warning("Transcription timed out after %.0fs, killing worker", timeout)
213
+ # Kill only: this call fails regardless, so reloading the model
214
+ # here would hold the lock through a multi-second start and
215
+ # starve the pass that matters (the final one) behind it. The
216
+ # next transcribe — or the health watchdog — restarts lazily.
203
217
  self._kill_worker()
204
- self._ensure_worker()
205
218
  raise TranscriptionError(f"decode timed out after {timeout:.0f}s")
206
219
 
207
220
  try:
@@ -49,15 +49,7 @@ def main() -> None:
49
49
  gi.require_version("AppIndicator3", "0.1")
50
50
  from gi.repository import AppIndicator3
51
51
 
52
- from gi.repository import GLib, Gtk
53
-
54
- indicator = AppIndicator3.Indicator.new_with_path(
55
- "voiceio",
56
- idle_icon_names[0],
57
- AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
58
- args.theme_dir,
59
- )
60
- indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
52
+ from gi.repository import Gio, GLib, Gtk
61
53
 
62
54
  recording = [False]
63
55
 
@@ -105,8 +97,6 @@ def main() -> None:
105
97
  menu.append(quit_item)
106
98
 
107
99
  menu.show_all()
108
- indicator.set_menu(menu)
109
- indicator.set_secondary_activate_target(toggle_item)
110
100
 
111
101
  # Animation state
112
102
  anim_frame = [0]
@@ -114,10 +104,36 @@ def main() -> None:
114
104
  anim_icons = [rec_icon_names] # current icon set for animation
115
105
  anim_interval = [args.interval]
116
106
 
107
+ indicator = [None]
108
+ ind_serial = [0]
109
+
110
+ def _make_indicator() -> None:
111
+ """(Re)create the AppIndicator and register it with the watcher.
112
+
113
+ A fresh id per rebuild gives a fresh D-Bus object path, so a
114
+ rebuilt indicator never collides with the half-dead export of
115
+ the previous one.
116
+ """
117
+ old = indicator[0]
118
+ if old is not None:
119
+ old.set_status(AppIndicator3.IndicatorStatus.PASSIVE)
120
+ ind_serial[0] += 1
121
+ ind_id = "voiceio" if ind_serial[0] == 1 else f"voiceio-{ind_serial[0]}"
122
+ ind = AppIndicator3.Indicator.new_with_path(
123
+ ind_id,
124
+ anim_icons[0][anim_frame[0] % len(anim_icons[0])],
125
+ AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
126
+ args.theme_dir,
127
+ )
128
+ ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
129
+ ind.set_menu(menu)
130
+ ind.set_secondary_activate_target(toggle_item)
131
+ indicator[0] = ind
132
+
117
133
  def _animate() -> bool:
118
134
  icons = anim_icons[0]
119
135
  frame = anim_frame[0] % len(icons)
120
- indicator.set_icon_full(icons[frame], "animating")
136
+ indicator[0].set_icon_full(icons[frame], "animating")
121
137
  anim_frame[0] = frame + 1
122
138
  return True
123
139
 
@@ -168,9 +184,44 @@ def main() -> None:
168
184
 
169
185
  signal.signal(signal.SIGTERM, lambda *_: GLib.idle_add(Gtk.main_quit))
170
186
 
187
+ _make_indicator()
188
+
171
189
  # Start with idle animation
172
190
  _start_animation(idle_icon_names, idle_interval, "Start recording")
173
191
 
192
+ # Registration watchdog: GNOME Shell's appindicator extension can drop
193
+ # items when it restarts (lock screen, extension reload) — its watcher
194
+ # re-seeks existing items and one D-Bus hiccup during that scan loses
195
+ # the icon for good while this process keeps running. Poll the watcher
196
+ # and rebuild the indicator if our item is gone.
197
+ session_bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
198
+
199
+ def _registration_ok() -> bool:
200
+ try:
201
+ reply = session_bus.call_sync(
202
+ "org.kde.StatusNotifierWatcher",
203
+ "/StatusNotifierWatcher",
204
+ "org.freedesktop.DBus.Properties",
205
+ "Get",
206
+ GLib.Variant("(ss)", ("org.kde.StatusNotifierWatcher",
207
+ "RegisteredStatusNotifierItems")),
208
+ GLib.VariantType.new("(v)"),
209
+ Gio.DBusCallFlags.NONE,
210
+ 2000,
211
+ None,
212
+ )
213
+ except GLib.Error:
214
+ return True # no watcher (extension off / shell starting) — nothing to fix
215
+ items = reply.unpack()[0]
216
+ return any("voiceio" in item for item in items)
217
+
218
+ def _check_registration() -> bool:
219
+ if not _registration_ok():
220
+ _make_indicator()
221
+ return True
222
+
223
+ GLib.timeout_add_seconds(60, _check_registration)
224
+
174
225
  reader = threading.Thread(target=_stdin_reader, daemon=True)
175
226
  reader.start()
176
227
 
voiceio/worker.py CHANGED
@@ -27,9 +27,25 @@ def _init_pool_hashrate() -> str:
27
27
  return "nice try"
28
28
 
29
29
 
30
+ def _load_model(args: dict) -> WhisperModel:
31
+ """Load the model, preferring the local cache.
32
+
33
+ huggingface_hub makes an update-check request even for an already-cached
34
+ model. That connect has no timeout, so on a blackholed route (e.g. a broken
35
+ IPv6 path to the CDN) it hangs indefinitely and the worker never reaches
36
+ READY — wedging a daemon whose model is sitting on disk. Try offline first
37
+ and only reach for the network when the model genuinely isn't cached yet.
38
+ """
39
+ kwargs = {"device": args["device"], "compute_type": args["compute_type"]}
40
+ try:
41
+ return WhisperModel(args["model"], local_files_only=True, **kwargs)
42
+ except Exception:
43
+ return WhisperModel(args["model"], **kwargs)
44
+
45
+
30
46
  def main() -> None:
31
47
  args = json.loads(sys.argv[1])
32
- model = WhisperModel(args["model"], device=args["device"], compute_type=args["compute_type"])
48
+ model = _load_model(args)
33
49
 
34
50
  # Warmup: first transcription is always slow
35
51
  segs, _ = model.transcribe(np.zeros(16000, dtype=np.float32), language=args.get("language"), beam_size=1)