python-voiceio 0.7.1__tar.gz → 0.8.1__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 (126) hide show
  1. {python_voiceio-0.7.1/python_voiceio.egg-info → python_voiceio-0.8.1}/PKG-INFO +1 -1
  2. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/pyproject.toml +1 -1
  3. {python_voiceio-0.7.1 → python_voiceio-0.8.1/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/python_voiceio.egg-info/SOURCES.txt +1 -0
  5. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_app_wiring.py +27 -6
  6. python_voiceio-0.8.1/tests/test_ibus_ping.py +50 -0
  7. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_robustness.py +29 -0
  8. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_streaming.py +29 -21
  9. python_voiceio-0.8.1/voiceio/__init__.py +1 -0
  10. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/app.py +53 -32
  11. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/config.py +6 -4
  12. python_voiceio-0.8.1/voiceio/ibus/__init__.py +33 -0
  13. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/ibus/engine.py +11 -1
  14. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/streaming.py +78 -31
  15. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tray/_indicator.py +63 -12
  16. python_voiceio-0.7.1/voiceio/__init__.py +0 -1
  17. python_voiceio-0.7.1/voiceio/ibus/__init__.py +0 -8
  18. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/LICENSE +0 -0
  19. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/README.md +0 -0
  20. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
  21. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/python_voiceio.egg-info/entry_points.txt +0 -0
  22. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/python_voiceio.egg-info/requires.txt +0 -0
  23. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/python_voiceio.egg-info/top_level.txt +0 -0
  24. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/setup.cfg +0 -0
  25. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_adjudicate.py +0 -0
  26. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_audio_quality.py +0 -0
  27. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_audit.py +0 -0
  28. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_autocorrect.py +0 -0
  29. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_autocorrect_state.py +0 -0
  30. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_backend_probes.py +0 -0
  31. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_cli.py +0 -0
  32. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_clipboard_read.py +0 -0
  33. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_commands.py +0 -0
  34. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_concurrency_lockdown.py +0 -0
  35. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_config.py +0 -0
  36. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_correct_batch.py +0 -0
  37. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_corrections.py +0 -0
  38. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_fallback.py +0 -0
  39. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_health.py +0 -0
  40. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_hints.py +0 -0
  41. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_history.py +0 -0
  42. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_ibus_pending.py +0 -0
  43. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_ibus_typer.py +0 -0
  44. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_llm_api.py +0 -0
  46. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_numbers.py +0 -0
  47. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_platform.py +0 -0
  48. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_postcorrect.py +0 -0
  49. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_postprocess.py +0 -0
  50. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_prebuffer.py +0 -0
  51. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_prompt.py +0 -0
  52. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_recorder_integration.py +0 -0
  53. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_retention.py +0 -0
  54. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_security_hardening.py +0 -0
  55. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_snapshots.py +0 -0
  56. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_transcriber.py +0 -0
  57. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_tts.py +0 -0
  58. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_vad.py +0 -0
  59. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_vocabulary.py +0 -0
  60. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_wizard.py +0 -0
  61. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/tests/test_wordfreq.py +0 -0
  62. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/__main__.py +0 -0
  63. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/audit.py +0 -0
  64. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/autocorrect.py +0 -0
  65. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/autocorrect_state.py +0 -0
  66. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/backends.py +0 -0
  67. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/cli.py +0 -0
  68. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/clipboard_read.py +0 -0
  69. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/commands.py +0 -0
  70. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/consent.py +0 -0
  71. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/corrections.py +0 -0
  72. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/demo.py +0 -0
  73. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/feedback.py +0 -0
  74. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/health.py +0 -0
  75. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hints.py +0 -0
  76. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/history.py +0 -0
  77. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/__init__.py +0 -0
  78. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/base.py +0 -0
  79. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/chain.py +0 -0
  80. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/evdev.py +0 -0
  81. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/pynput_backend.py +0 -0
  82. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/hotkeys/socket_backend.py +0 -0
  83. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/ibus/pending.py +0 -0
  84. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/llm.py +0 -0
  85. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/llm_api.py +0 -0
  86. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/models/__init__.py +0 -0
  87. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/models/silero_vad.onnx +0 -0
  88. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/numbers.py +0 -0
  89. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/pidlock.py +0 -0
  90. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/platform.py +0 -0
  91. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/postcorrect.py +0 -0
  92. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/postprocess.py +0 -0
  93. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/prompt.py +0 -0
  94. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/recorder.py +0 -0
  95. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/retention.py +0 -0
  96. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/service.py +0 -0
  97. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/snapshots.py +0 -0
  98. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/sounds/__init__.py +0 -0
  99. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/sounds/commit.wav +0 -0
  100. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/sounds/start.wav +0 -0
  101. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/sounds/stop.wav +0 -0
  102. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/transcriber.py +0 -0
  103. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tray/__init__.py +0 -0
  104. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tray/_icons.py +0 -0
  105. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tray/_pystray.py +0 -0
  106. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/__init__.py +0 -0
  107. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/base.py +0 -0
  108. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/chain.py +0 -0
  109. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/edge_engine.py +0 -0
  110. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/espeak.py +0 -0
  111. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/piper_engine.py +0 -0
  112. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/tts/player.py +0 -0
  113. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/__init__.py +0 -0
  114. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/base.py +0 -0
  115. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/chain.py +0 -0
  116. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/clipboard.py +0 -0
  117. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/ibus.py +0 -0
  118. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/pynput_type.py +0 -0
  119. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/wtype.py +0 -0
  120. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/xdotool.py +0 -0
  121. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/typers/ydotool.py +0 -0
  122. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/vad.py +0 -0
  123. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/vocabulary.py +0 -0
  124. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/wizard.py +0 -0
  125. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/wordfreq.py +0 -0
  126. {python_voiceio-0.7.1 → python_voiceio-0.8.1}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.7.1
3
+ Version: 0.8.1
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.7.1"
7
+ version = "0.8.1"
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.7.1
3
+ Version: 0.8.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ tests/test_health.py
26
26
  tests/test_hints.py
27
27
  tests/test_history.py
28
28
  tests/test_ibus_pending.py
29
+ tests/test_ibus_ping.py
29
30
  tests/test_ibus_typer.py
30
31
  tests/test_llm.py
31
32
  tests/test_llm_api.py
@@ -274,14 +274,16 @@ class TestEngineLifecycle:
274
274
  vio._engine_proc = proc
275
275
  return vio, proc
276
276
 
277
- def test_idle_engine_death_not_recovered(self):
278
- """ibus-daemon reaping a dormant engine must NOT trigger the
279
- activate/dormant dance (it grabbed the input source every cycle)."""
277
+ def test_idle_engine_death_respawns_without_activation(self):
278
+ """ibus-daemon reaping a dormant engine must respawn it registered
279
+ but NEVER activated — the activate/dormant dance grabbed the input
280
+ source every cycle, and leaving the engine dead let ibus-daemon
281
+ exec-spawn a socket-stealing duplicate on accidental activation."""
280
282
  vio, proc = self._vio_ibus(proc_poll=-15)
281
283
  with patch.object(vio, "_ensure_ibus_engine") as ensure, \
282
284
  patch.object(vio, "_health_typer_upkeep"):
283
285
  vio._check_health()
284
- ensure.assert_not_called()
286
+ ensure.assert_called_once_with(activate=False)
285
287
  assert vio._engine_proc is None
286
288
 
287
289
  def test_mid_recording_engine_death_recovered(self):
@@ -290,7 +292,7 @@ class TestEngineLifecycle:
290
292
  vio._state = _State.RECORDING
291
293
  with patch.object(vio, "_ensure_ibus_engine") as ensure:
292
294
  vio._check_health()
293
- ensure.assert_called_once()
295
+ ensure.assert_called_once_with() # default activate=True
294
296
 
295
297
  def test_single_missed_ping_tolerated(self):
296
298
  """One missed ping on a loaded system is not a zombie."""
@@ -310,9 +312,28 @@ class TestEngineLifecycle:
310
312
  patch.object(vio, "_health_typer_upkeep"):
311
313
  vio._check_health()
312
314
  vio._check_health()
313
- ensure.assert_called_once()
315
+ ensure.assert_called_once_with(activate=False)
314
316
  proc.kill.assert_called_once()
315
317
 
318
+ def test_dormant_spawn_never_touches_input_source(self):
319
+ """activate=False must not run `ibus engine voiceio` or gsettings —
320
+ idle spawns that claimed the source were the focus churn that wiped
321
+ visible preedits and stole the keyboard mid-typing."""
322
+ vio, _, _ = _make_vio()
323
+ with patch("voiceio.app.subprocess.run") as run, \
324
+ patch("voiceio.app.subprocess.Popen") as popen, \
325
+ patch.object(vio, "_kill_stale_engine"), \
326
+ patch("voiceio.ibus.SOCKET_PATH") as sock_path, \
327
+ patch("voiceio.ibus.READY_PATH"):
328
+ sock_path.exists.return_value = True
329
+ run.return_value = MagicMock(returncode=0, stdout="xkb:us::eng\n")
330
+ vio._spawn_ibus_engine(activate=False)
331
+ assert vio._engine_proc is not None # engine process was spawned
332
+ run_cmds = [c[0][0] for c in run.call_args_list]
333
+ popen_cmds = [c[0][0] for c in popen.call_args_list if c[0]]
334
+ assert ["ibus", "engine", "voiceio"] not in run_cmds + popen_cmds
335
+ assert not any("gsettings" in cmd[0] for cmd in run_cmds + popen_cmds)
336
+
316
337
  def test_successful_ping_resets_counter(self):
317
338
  vio, proc = self._vio_ibus(proc_poll=None)
318
339
  vio._engine_ping_fails = 1
@@ -0,0 +1,50 @@
1
+ """Tests for the shared engine ping used by the daemon health watchdog and
2
+ the engine's own refuse-to-double-run guard (a duplicate engine binding the
3
+ socket path silently starves the healthy instance)."""
4
+ from __future__ import annotations
5
+
6
+ import socket
7
+ import threading
8
+
9
+ import voiceio.ibus as ibus_mod
10
+
11
+
12
+ def test_no_socket_returns_false(tmp_path, monkeypatch):
13
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
14
+ assert ibus_mod.ping_engine(timeout=0.2) is False
15
+
16
+
17
+ def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
18
+ sock_path = tmp_path / "engine.sock"
19
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
20
+ server.bind(str(sock_path))
21
+ server.settimeout(2.0)
22
+
23
+ def _serve():
24
+ try:
25
+ data, addr = server.recvfrom(64)
26
+ if data == b"ping" and addr:
27
+ server.sendto(b"pong", addr)
28
+ except OSError:
29
+ pass
30
+
31
+ t = threading.Thread(target=_serve, daemon=True)
32
+ t.start()
33
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
34
+ try:
35
+ assert ibus_mod.ping_engine(timeout=1.0) is True
36
+ finally:
37
+ t.join(timeout=2.0)
38
+ server.close()
39
+
40
+
41
+ def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
42
+ """A bound-but-silent socket (starved listener) must read as dead."""
43
+ sock_path = tmp_path / "engine.sock"
44
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
45
+ server.bind(str(sock_path))
46
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
47
+ try:
48
+ assert ibus_mod.ping_engine(timeout=0.2) is False
49
+ finally:
50
+ server.close()
@@ -578,6 +578,7 @@ class TestBootRaceAndReProbe:
578
578
 
579
579
  with patch.dict("os.environ", {
580
580
  "DISPLAY": ":0",
581
+ "XAUTHORITY": "/run/user/1000/.mutter-Xwaylandauth.TEST",
581
582
  "WAYLAND_DISPLAY": "wayland-0",
582
583
  "XDG_SESSION_TYPE": "wayland",
583
584
  "XDG_CURRENT_DESKTOP": "GNOME",
@@ -592,6 +593,34 @@ class TestBootRaceAndReProbe:
592
593
 
593
594
  app_mod._graphical_env_complete = False
594
595
 
596
+ def test_import_graphical_env_pulls_xauthority(self):
597
+ """Without the X auth cookie, xclip fails silently inside the daemon
598
+ and clipboard copies fall back to focus-stealing wl-copy — whose
599
+ focus-out discards any visible IBus preedit (the flashing preview)."""
600
+ import os
601
+
602
+ from voiceio import app as app_mod
603
+ from voiceio.app import _import_graphical_env
604
+
605
+ app_mod._graphical_env_complete = False
606
+ env = {
607
+ "DISPLAY": ":0",
608
+ "WAYLAND_DISPLAY": "wayland-0",
609
+ "XDG_SESSION_TYPE": "wayland",
610
+ "XDG_CURRENT_DESKTOP": "GNOME",
611
+ "XDG_SESSION_DESKTOP": "gnome",
612
+ }
613
+ with patch.dict("os.environ", env, clear=True):
614
+ with patch(
615
+ "voiceio.app.subprocess.check_output",
616
+ return_value="XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.TEST\n",
617
+ ):
618
+ _import_graphical_env()
619
+ assert os.environ["XAUTHORITY"] == "/run/user/1000/.mutter-Xwaylandauth.TEST"
620
+ assert app_mod._graphical_env_complete is True
621
+
622
+ app_mod._graphical_env_complete = False
623
+
595
624
  def test_try_upgrade_typer_upgrades_from_clipboard_to_ibus(self):
596
625
  """When chain has ibus first and current is clipboard, upgrade."""
597
626
  vio, _, _ = _make_vio()
@@ -678,15 +678,21 @@ class TestSegmentFreeze:
678
678
  )
679
679
  return session, transcriber, recorder
680
680
 
681
- def test_freeze_triggers_on_long_silent_tail(self):
681
+ def test_freeze_cuts_at_interior_pause(self):
682
682
  s, tr, rec = self._make_session(freeze_secs=2.0)
683
- rec.get_audio_so_far.return_value = _silent(3)
683
+ # 2.5s speech, 0.5s pause, 2s speech: cut must land in the pause
684
+ rec.get_audio_so_far.return_value = np.concatenate(
685
+ [_loud(2.5), _silent(0.5), _loud(2.0)])
684
686
  tr.transcribe.return_value = "hello there friend"
685
687
  s._transcribe_and_apply()
686
688
  assert s._frozen_raw == "hello there friend"
687
- assert s._frozen_samples == SR * 3
688
- # Freeze pass gets beam search (final=True)
689
+ # Boundary inside the pause (2.5s..3.0s), NOT at the end of audio
690
+ assert SR * 2.5 <= s._frozen_samples <= SR * 3.0
691
+ # Freeze pass gets beam search and decodes ONLY the chunk
689
692
  assert tr.transcribe.call_args.kwargs["final"] is True
693
+ assert len(tr.transcribe.call_args.args[0]) == s._frozen_samples
694
+ # Display untouched: interim text already covers this audio
695
+ s._typer.type_text.assert_not_called()
690
696
 
691
697
  def test_no_freeze_when_tail_is_speech(self):
692
698
  s, tr, rec = self._make_session(freeze_secs=2.0)
@@ -785,16 +791,15 @@ class TestDecodeTrace:
785
791
  freeze_secs=2.0,
786
792
  )
787
793
  transcriber.transcribe.return_value = "hello there friend"
788
- rec_audio = _silent(3)
789
- recorder.get_audio_so_far.return_value = rec_audio
790
- s._transcribe_and_apply() # freeze pass
794
+ recorder.get_audio_so_far.return_value = _silent(3)
795
+ s._transcribe_and_apply() # freeze pass (interior cut in silence)
791
796
  s._final_audio = np.concatenate([_silent(3), _loud(1)])
792
797
  transcriber.transcribe.return_value = "bye"
793
798
  s._transcribe_and_apply(min_seconds=0.5, final=True)
794
799
  kinds = [p["kind"] for p in s.trace]
795
800
  assert kinds == ["freeze", "final"]
796
801
  assert s.trace[0]["text"] == "hello there friend"
797
- assert s.trace[1]["tail_secs"] == 1.0
802
+ assert s.trace[1]["tail_secs"] > 0
798
803
  assert all("secs" in p for p in s.trace)
799
804
 
800
805
 
@@ -856,31 +861,34 @@ class TestDecodeFailureSafety:
856
861
  s._typer.delete_chars.assert_not_called()
857
862
  assert s._typed_text == "hello there friend how are"
858
863
 
859
- def test_quiet_mic_speech_is_not_silence(self):
860
- from voiceio.streaming import _tail_ends_in_silence
864
+ def test_quiet_mic_speech_finds_no_cut(self):
865
+ from voiceio.streaming import _find_freeze_cut
861
866
  # Continuous speech on a low-gain mic: quiet in absolute terms but
862
- # uniform — trailing window matches overall level, so NOT a pause.
867
+ # uniform — no window is quiet relative to the tail, so no cut.
863
868
  quiet_speech = np.full(SR * 3, 0.005, dtype=np.float32)
864
- assert not _tail_ends_in_silence(quiet_speech, SR)
865
- # Same quiet mic with a real trailing pause IS a pause.
869
+ assert _find_freeze_cut(quiet_speech, SR, 2.0) is None
870
+ # Same quiet mic with a real pause after the threshold IS cuttable.
866
871
  with_pause = np.concatenate([
867
- np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
872
+ np.full(int(SR * 2.2), 0.005, dtype=np.float32), _silent(0.5),
873
+ np.full(int(SR * 0.3), 0.005, dtype=np.float32),
868
874
  ])
869
- assert _tail_ends_in_silence(with_pause, SR)
875
+ cut = _find_freeze_cut(with_pause, SR, 2.0)
876
+ assert cut is not None and SR * 2.2 <= cut <= SR * 2.8
870
877
 
871
878
 
872
879
  class TestFreezeCalibration:
873
- def test_hot_noise_floor_pause_is_silence(self):
880
+ def test_hot_noise_floor_pause_is_cuttable(self):
874
881
  """Real-mic calibration: noise floor ~0.3x of tail RMS IS a pause."""
875
- from voiceio.streaming import _tail_ends_in_silence
882
+ from voiceio.streaming import _find_freeze_cut
876
883
  speech = np.full(int(SR * 2.5), 0.12, dtype=np.float32)
877
884
  pause = np.full(int(SR * 0.5), 0.03, dtype=np.float32) # noise floor
878
- assert _tail_ends_in_silence(np.concatenate([speech, pause]), SR)
885
+ cut = _find_freeze_cut(np.concatenate([speech, pause]), SR, 2.0)
886
+ assert cut is not None and cut >= SR * 2.3
879
887
 
880
- def test_speech_level_window_is_not_silence(self):
881
- from voiceio.streaming import _tail_ends_in_silence
888
+ def test_speech_level_tail_has_no_cut(self):
889
+ from voiceio.streaming import _find_freeze_cut
882
890
  tail = np.full(SR * 3, 0.12, dtype=np.float32)
883
- assert not _tail_ends_in_silence(tail, SR)
891
+ assert _find_freeze_cut(tail, SR, 2.0) is None
884
892
 
885
893
 
886
894
  class TestDecodeBackpressure:
@@ -0,0 +1 @@
1
+ __version__ = "0.8.1"
@@ -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,
@@ -85,10 +85,12 @@ class OutputConfig:
85
85
  number_conversion: bool = True
86
86
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
87
87
  # Incremental finalization: once the un-finalized audio tail grows past
88
- # this many seconds AND ends in silence, beam-decode and freeze it during
89
- # recording, so interim passes and the stop-time final decode only cover
90
- # the short remaining tail. 0 disables (full re-decode at stop).
91
- streaming_freeze_secs: float = 25.0
88
+ # this many seconds, it is cut at the nearest interior speech pause,
89
+ # beam-decoded and frozen during recording, so interim passes and the
90
+ # stop-time final decode only cover the short remaining tail. Smaller
91
+ # chunks decode faster (less worker blocking) at slightly more boundary
92
+ # risk. 0 disables (full re-decode at stop).
93
+ streaming_freeze_secs: float = 15.0
92
94
  # Mirror transcribed text to the system clipboard so it can be pasted:
93
95
  # "off" — never
94
96
  # "final" — the corrected final text, once ready
@@ -0,0 +1,33 @@
1
+ """IBus input method engine for VoiceIO."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
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()
@@ -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