python-voiceio 0.7.0__tar.gz → 0.8.0__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 (124) hide show
  1. {python_voiceio-0.7.0/python_voiceio.egg-info → python_voiceio-0.8.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.7.0 → python_voiceio-0.8.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_app_wiring.py +125 -0
  5. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_clipboard_read.py +30 -2
  6. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_ibus_typer.py +4 -7
  7. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_streaming.py +29 -21
  8. python_voiceio-0.8.0/voiceio/__init__.py +1 -0
  9. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/app.py +136 -27
  10. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/clipboard_read.py +18 -2
  11. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/config.py +6 -4
  12. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/engine.py +4 -0
  13. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/streaming.py +78 -31
  14. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/ibus.py +8 -16
  15. python_voiceio-0.7.0/voiceio/__init__.py +0 -1
  16. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/LICENSE +0 -0
  17. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/README.md +0 -0
  18. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  19. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  20. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  21. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/requires.txt +0 -0
  22. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/top_level.txt +0 -0
  23. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/setup.cfg +0 -0
  24. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_adjudicate.py +0 -0
  25. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_audio_quality.py +0 -0
  26. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_audit.py +0 -0
  27. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_autocorrect.py +0 -0
  28. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_autocorrect_state.py +0 -0
  29. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_backend_probes.py +0 -0
  30. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_cli.py +0 -0
  31. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_commands.py +0 -0
  32. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_concurrency_lockdown.py +0 -0
  33. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_config.py +0 -0
  34. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_correct_batch.py +0 -0
  35. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_corrections.py +0 -0
  36. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_fallback.py +0 -0
  37. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_health.py +0 -0
  38. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_hints.py +0 -0
  39. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_history.py +0 -0
  40. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_ibus_pending.py +0 -0
  41. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_llm.py +0 -0
  42. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_llm_api.py +0 -0
  43. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_numbers.py +0 -0
  44. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_platform.py +0 -0
  45. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_postcorrect.py +0 -0
  46. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_postprocess.py +0 -0
  47. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_prebuffer.py +0 -0
  48. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_prompt.py +0 -0
  49. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_recorder_integration.py +0 -0
  50. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_retention.py +0 -0
  51. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_robustness.py +0 -0
  52. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_security_hardening.py +0 -0
  53. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_snapshots.py +0 -0
  54. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_transcriber.py +0 -0
  55. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_tts.py +0 -0
  56. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_vad.py +0 -0
  57. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_vocabulary.py +0 -0
  58. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_wizard.py +0 -0
  59. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_wordfreq.py +0 -0
  60. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/__main__.py +0 -0
  61. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/audit.py +0 -0
  62. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/autocorrect.py +0 -0
  63. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/autocorrect_state.py +0 -0
  64. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/backends.py +0 -0
  65. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/cli.py +0 -0
  66. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/commands.py +0 -0
  67. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/consent.py +0 -0
  68. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/corrections.py +0 -0
  69. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/demo.py +0 -0
  70. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/feedback.py +0 -0
  71. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/health.py +0 -0
  72. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hints.py +0 -0
  73. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/history.py +0 -0
  74. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/__init__.py +0 -0
  75. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/base.py +0 -0
  76. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/chain.py +0 -0
  77. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/evdev.py +0 -0
  78. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  79. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/socket_backend.py +0 -0
  80. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/__init__.py +0 -0
  81. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/pending.py +0 -0
  82. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/llm.py +0 -0
  83. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/llm_api.py +0 -0
  84. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/models/__init__.py +0 -0
  85. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/models/silero_vad.onnx +0 -0
  86. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/numbers.py +0 -0
  87. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/pidlock.py +0 -0
  88. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/platform.py +0 -0
  89. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/postcorrect.py +0 -0
  90. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/postprocess.py +0 -0
  91. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/prompt.py +0 -0
  92. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/recorder.py +0 -0
  93. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/retention.py +0 -0
  94. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/service.py +0 -0
  95. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/snapshots.py +0 -0
  96. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/__init__.py +0 -0
  97. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/commit.wav +0 -0
  98. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/start.wav +0 -0
  99. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/stop.wav +0 -0
  100. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/transcriber.py +0 -0
  101. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/pynput_type.py +0 -0
  117. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/wtype.py +0 -0
  118. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/xdotool.py +0 -0
  119. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/ydotool.py +0 -0
  120. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/vad.py +0 -0
  121. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/vocabulary.py +0 -0
  122. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/wizard.py +0 -0
  123. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.7.0
3
+ Version: 0.8.0
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.0"
7
+ version = "0.8.0"
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.0
3
+ Version: 0.8.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -260,3 +260,128 @@ class TestClipboardMirror:
260
260
  vio._copy_result_async("hello world")
261
261
  assert done.wait(timeout=2)
262
262
  mock_copy.assert_called_once_with("hello world")
263
+
264
+
265
+ class TestEngineLifecycle:
266
+ """Dormant-engine reaping is normal; recovery must not steal the source."""
267
+
268
+ def _vio_ibus(self, proc_poll=-15):
269
+ vio, mock_typer, _ = _make_vio()
270
+ mock_typer.name = "ibus"
271
+ proc = MagicMock()
272
+ proc.poll.return_value = proc_poll
273
+ proc.returncode = -15
274
+ vio._engine_proc = proc
275
+ return vio, proc
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)."""
280
+ vio, proc = self._vio_ibus(proc_poll=-15)
281
+ with patch.object(vio, "_ensure_ibus_engine") as ensure, \
282
+ patch.object(vio, "_health_typer_upkeep"):
283
+ vio._check_health()
284
+ ensure.assert_not_called()
285
+ assert vio._engine_proc is None
286
+
287
+ def test_mid_recording_engine_death_recovered(self):
288
+ from voiceio.app import _State
289
+ vio, proc = self._vio_ibus(proc_poll=-15)
290
+ vio._state = _State.RECORDING
291
+ with patch.object(vio, "_ensure_ibus_engine") as ensure:
292
+ vio._check_health()
293
+ ensure.assert_called_once()
294
+
295
+ def test_single_missed_ping_tolerated(self):
296
+ """One missed ping on a loaded system is not a zombie."""
297
+ vio, proc = self._vio_ibus(proc_poll=None)
298
+ with patch.object(vio, "_ping_ibus_engine", return_value=False), \
299
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
300
+ patch.object(vio, "_health_typer_upkeep"):
301
+ vio._check_health()
302
+ ensure.assert_not_called()
303
+ proc.kill.assert_not_called()
304
+ assert vio._engine_ping_fails == 1
305
+
306
+ def test_two_missed_pings_restart_zombie(self):
307
+ vio, proc = self._vio_ibus(proc_poll=None)
308
+ with patch.object(vio, "_ping_ibus_engine", return_value=False), \
309
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
310
+ patch.object(vio, "_health_typer_upkeep"):
311
+ vio._check_health()
312
+ vio._check_health()
313
+ ensure.assert_called_once()
314
+ proc.kill.assert_called_once()
315
+
316
+ def test_successful_ping_resets_counter(self):
317
+ vio, proc = self._vio_ibus(proc_poll=None)
318
+ vio._engine_ping_fails = 1
319
+ with patch.object(vio, "_ping_ibus_engine", return_value=True), \
320
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
321
+ patch.object(vio, "_health_typer_upkeep"):
322
+ vio._check_health()
323
+ ensure.assert_not_called()
324
+ assert vio._engine_ping_fails == 0
325
+
326
+ def test_record_start_respawns_reaped_engine(self):
327
+ vio, mock_typer, _ = _make_vio()
328
+ mock_typer.name = "ibus"
329
+ vio._engine_proc = None # reaped while dormant
330
+ spawned = threading.Event()
331
+ with patch.object(vio, "_ensure_ibus_engine", side_effect=lambda: spawned.set()), \
332
+ patch.object(vio, "_switch_gnome_input_source"):
333
+ vio.on_hotkey() # start recording
334
+ assert spawned.wait(timeout=2), "engine respawn not triggered"
335
+ assert vio.recorder.is_recording
336
+
337
+
338
+ class TestEngineRelease:
339
+ """Going dormant must release ibus's GLOBAL engine, not just gsettings."""
340
+
341
+ def test_release_sets_user_engine_unconditionally(self):
342
+ """Release must not query first: a stale reading during the async
343
+ activation made it skip itself and leave voiceio globally active."""
344
+ vio, _, _ = _make_vio()
345
+ vio._prev_ibus_engine = "xkb:us::eng"
346
+ with patch("voiceio.app.subprocess.run") as run:
347
+ run.return_value = MagicMock(returncode=0)
348
+ vio._release_ibus_engine()
349
+ assert run.call_count == 1
350
+ assert run.call_args[0][0] == ["ibus", "engine", "xkb:us::eng"]
351
+
352
+ def test_fallback_derived_from_sources_when_prev_stale(self):
353
+ """A stale 'voiceio' prev (inherited from a crashed daemon) must not
354
+ be restored; derive the user's xkb layout instead."""
355
+ vio, _, _ = _make_vio()
356
+ vio._prev_ibus_engine = "voiceio"
357
+ with patch("voiceio.app.subprocess.run") as run:
358
+ run.return_value = MagicMock(
359
+ returncode=0, stdout="[('xkb', 'de'), ('ibus', 'voiceio')]\n",
360
+ )
361
+ assert vio._ibus_engine_fallback() == "xkb:de::eng"
362
+
363
+ def test_restore_input_source_releases_engine(self):
364
+ vio, _, _ = _make_vio()
365
+ vio.platform.is_gnome = True
366
+ with patch.object(vio, "_set_gnome_input_source_index"), \
367
+ patch.object(vio, "_release_ibus_engine") as release:
368
+ vio._restore_input_source()
369
+ release.assert_called_once()
370
+
371
+
372
+ def test_activation_claims_global_engine():
373
+ """gsettings 'current' is legacy/no-op on newer GNOME — activation must
374
+ also claim the global engine directly (mirror of the dormant release)."""
375
+ vio, _, _ = _make_vio()
376
+ vio.platform.is_gnome = True
377
+ with patch("voiceio.app.subprocess.run") as run:
378
+ run.side_effect = [
379
+ MagicMock(returncode=0, stdout="[('xkb', 'us'), ('ibus', 'voiceio')]\n"), # sources
380
+ MagicMock(returncode=0, stdout="0\n"), # current (prev)
381
+ MagicMock(returncode=0), # gsettings set current
382
+ MagicMock(returncode=0), # ibus engine voiceio
383
+ ]
384
+ with patch("time.sleep"):
385
+ vio._switch_gnome_input_source("voiceio")
386
+ cmds = [c[0][0] for c in run.call_args_list]
387
+ assert ["ibus", "engine", "voiceio"] in cmds
@@ -94,9 +94,9 @@ def test_read_text_no_tools():
94
94
  # --- copy_text ---
95
95
 
96
96
  def test_copy_text_wayland():
97
- """Wayland copies via wl-copy with text on stdin."""
97
+ """Wayland without xclip copies via wl-copy with text on stdin."""
98
98
  with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
99
- patch("shutil.which", return_value="/usr/bin/wl-copy"), \
99
+ patch("shutil.which", lambda t: "/usr/bin/wl-copy" if t == "wl-copy" else None), \
100
100
  patch("subprocess.run") as mock_run:
101
101
  mock_run.return_value = MagicMock(returncode=0)
102
102
  from voiceio.clipboard_read import copy_text
@@ -143,3 +143,31 @@ def test_copy_text_command_failure():
143
143
  mock_run.return_value = MagicMock(returncode=1)
144
144
  from voiceio.clipboard_read import copy_text
145
145
  assert copy_text("hello") is False
146
+
147
+
148
+ def test_copy_text_wayland_prefers_xclip_bridge():
149
+ """On Wayland prefer xclip (XWayland bridge): wl-copy can steal focus
150
+ on GNOME, and a focus-out discards a visible IBus preedit."""
151
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
152
+ patch("shutil.which", lambda t: f"/usr/bin/{t}" if t in ("xclip", "wl-copy") else None), \
153
+ patch("subprocess.run") as mock_run:
154
+ mock_run.return_value = MagicMock(returncode=0)
155
+ from voiceio.clipboard_read import copy_text
156
+ assert copy_text("hello") is True
157
+ assert mock_run.call_args[0][0][0] == "xclip"
158
+
159
+
160
+ def test_copy_text_wayland_falls_back_to_wl_copy():
161
+ """xclip failing (no DISPLAY etc.) falls through to wl-copy."""
162
+ calls = []
163
+
164
+ def run(cmd, **kw):
165
+ calls.append(cmd[0])
166
+ return MagicMock(returncode=1 if cmd[0] == "xclip" else 0)
167
+
168
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
169
+ patch("shutil.which", lambda t: f"/usr/bin/{t}" if t in ("xclip", "wl-copy") else None), \
170
+ patch("subprocess.run", side_effect=run):
171
+ from voiceio.clipboard_read import copy_text
172
+ assert copy_text("hello") is True
173
+ assert calls == ["xclip", "wl-copy"]
@@ -103,16 +103,13 @@ class TestIBusTyper:
103
103
  typer.delete_chars(10) # should not raise
104
104
 
105
105
  def test_commit_copies_to_clipboard(self, mock_socket):
106
- """commit_text also copies text to clipboard as backup."""
106
+ """commit_text also copies text to clipboard as backup (via the
107
+ focus-safe clipboard_read.copy_text, never wl-copy directly)."""
107
108
  typer = IBusTyper()
108
- typer._wl_copy = "/usr/bin/wl-copy"
109
- mock_proc = MagicMock()
110
- mock_proc.stdin = MagicMock()
111
- with patch("voiceio.typers.ibus.subprocess.Popen", return_value=mock_proc), \
109
+ with patch("voiceio.clipboard_read.copy_text", return_value=True) as mock_copy, \
112
110
  patch("voiceio.typers.ibus.subprocess.run"):
113
111
  typer.commit_text("Hello")
114
- mock_proc.stdin.write.assert_called_once_with(b"Hello")
115
- mock_proc.stdin.close.assert_called_once()
112
+ mock_copy.assert_called_once_with("Hello")
116
113
 
117
114
  def test_probe_no_ibus(self):
118
115
  with patch("voiceio.typers.ibus.shutil.which", return_value=None):
@@ -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.0"
@@ -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.
@@ -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:
@@ -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
@@ -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):