python-voiceio 0.6.0__tar.gz → 0.7.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 (125) hide show
  1. {python_voiceio-0.6.0/python_voiceio.egg-info → python_voiceio-0.7.1}/PKG-INFO +1 -1
  2. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/pyproject.toml +1 -1
  3. {python_voiceio-0.6.0 → python_voiceio-0.7.1/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_app_wiring.py +125 -0
  5. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_clipboard_read.py +30 -2
  6. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_ibus_typer.py +4 -7
  7. python_voiceio-0.7.1/tests/test_llm_api.py +261 -0
  8. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_streaming.py +81 -0
  9. python_voiceio-0.7.1/voiceio/__init__.py +1 -0
  10. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/app.py +136 -27
  11. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/clipboard_read.py +18 -2
  12. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/config.py +6 -1
  13. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/engine.py +4 -0
  14. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/llm_api.py +60 -11
  15. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/streaming.py +22 -1
  16. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/ibus.py +8 -16
  17. python_voiceio-0.6.0/tests/test_llm_api.py +0 -220
  18. python_voiceio-0.6.0/voiceio/__init__.py +0 -1
  19. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/LICENSE +0 -0
  20. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/README.md +0 -0
  21. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/SOURCES.txt +0 -0
  22. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
  23. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/entry_points.txt +0 -0
  24. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/requires.txt +0 -0
  25. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/top_level.txt +0 -0
  26. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/setup.cfg +0 -0
  27. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_adjudicate.py +0 -0
  28. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_audio_quality.py +0 -0
  29. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_audit.py +0 -0
  30. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_autocorrect.py +0 -0
  31. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_autocorrect_state.py +0 -0
  32. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_backend_probes.py +0 -0
  33. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_cli.py +0 -0
  34. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_commands.py +0 -0
  35. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_concurrency_lockdown.py +0 -0
  36. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_config.py +0 -0
  37. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_correct_batch.py +0 -0
  38. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_corrections.py +0 -0
  39. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_fallback.py +0 -0
  40. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_health.py +0 -0
  41. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_hints.py +0 -0
  42. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_history.py +0 -0
  43. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_ibus_pending.py +0 -0
  44. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_numbers.py +0 -0
  46. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_platform.py +0 -0
  47. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_postcorrect.py +0 -0
  48. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_postprocess.py +0 -0
  49. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_prebuffer.py +0 -0
  50. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_prompt.py +0 -0
  51. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_recorder_integration.py +0 -0
  52. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_retention.py +0 -0
  53. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_robustness.py +0 -0
  54. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_security_hardening.py +0 -0
  55. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_snapshots.py +0 -0
  56. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_transcriber.py +0 -0
  57. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_tts.py +0 -0
  58. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_vad.py +0 -0
  59. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_vocabulary.py +0 -0
  60. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_wizard.py +0 -0
  61. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_wordfreq.py +0 -0
  62. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/__main__.py +0 -0
  63. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/audit.py +0 -0
  64. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/autocorrect.py +0 -0
  65. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/autocorrect_state.py +0 -0
  66. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/backends.py +0 -0
  67. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/cli.py +0 -0
  68. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/commands.py +0 -0
  69. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/consent.py +0 -0
  70. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/corrections.py +0 -0
  71. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/demo.py +0 -0
  72. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/feedback.py +0 -0
  73. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/health.py +0 -0
  74. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hints.py +0 -0
  75. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/history.py +0 -0
  76. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/__init__.py +0 -0
  77. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/base.py +0 -0
  78. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/chain.py +0 -0
  79. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/evdev.py +0 -0
  80. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/pynput_backend.py +0 -0
  81. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/socket_backend.py +0 -0
  82. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/__init__.py +0 -0
  83. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/pending.py +0 -0
  84. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/llm.py +0 -0
  85. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/models/__init__.py +0 -0
  86. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/models/silero_vad.onnx +0 -0
  87. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/numbers.py +0 -0
  88. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/pidlock.py +0 -0
  89. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/platform.py +0 -0
  90. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/postcorrect.py +0 -0
  91. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/postprocess.py +0 -0
  92. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/prompt.py +0 -0
  93. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/recorder.py +0 -0
  94. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/retention.py +0 -0
  95. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/service.py +0 -0
  96. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/snapshots.py +0 -0
  97. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/__init__.py +0 -0
  98. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/commit.wav +0 -0
  99. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/start.wav +0 -0
  100. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/stop.wav +0 -0
  101. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/transcriber.py +0 -0
  102. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/__init__.py +0 -0
  103. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_icons.py +0 -0
  104. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_indicator.py +0 -0
  105. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_pystray.py +0 -0
  106. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/__init__.py +0 -0
  107. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/base.py +0 -0
  108. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/chain.py +0 -0
  109. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/edge_engine.py +0 -0
  110. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/espeak.py +0 -0
  111. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/piper_engine.py +0 -0
  112. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/player.py +0 -0
  113. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/__init__.py +0 -0
  114. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/base.py +0 -0
  115. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/chain.py +0 -0
  116. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/clipboard.py +0 -0
  117. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/pynput_type.py +0 -0
  118. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/wtype.py +0 -0
  119. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/xdotool.py +0 -0
  120. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/ydotool.py +0 -0
  121. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/vad.py +0 -0
  122. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/vocabulary.py +0 -0
  123. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/wizard.py +0 -0
  124. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/wordfreq.py +0 -0
  125. {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.6.0"
7
+ version = "0.7.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.6.0
3
+ Version: 0.7.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -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):
@@ -0,0 +1,261 @@
1
+ """Tests for voiceio.llm_api — OpenAI-compatible chat completions client."""
2
+ from __future__ import annotations
3
+
4
+ import http.client
5
+ import json
6
+ import urllib.error
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ from voiceio import llm_api
12
+ from voiceio.config import AutocorrectConfig
13
+ from voiceio.llm_api import chat, check_api_key, detect_provider, resolve_api_key
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _clear_pool():
18
+ """Keep-alive pool is process-global state — isolate every test."""
19
+ llm_api._CONN_POOL.clear()
20
+ yield
21
+ llm_api._CONN_POOL.clear()
22
+
23
+
24
+ def _fake_conn(data: dict | None, status: int = 200) -> MagicMock:
25
+ """A mock http.client connection returning one JSON response."""
26
+ resp = MagicMock()
27
+ resp.status = status
28
+ resp.reason = "OK" if status == 200 else "ERR"
29
+ resp.read.return_value = json.dumps(data or {}).encode()
30
+ resp.getheaders.return_value = []
31
+ conn = MagicMock()
32
+ conn.getresponse.return_value = resp
33
+ return conn
34
+
35
+
36
+ def _request_kwargs(conn: MagicMock) -> tuple[str, dict, dict]:
37
+ """(path, headers, parsed body) of the single request made on conn."""
38
+ args, kwargs = conn.request.call_args
39
+ return args[1], kwargs["headers"], json.loads(kwargs["body"])
40
+
41
+
42
+ def _cfg(**kwargs) -> AutocorrectConfig:
43
+ defaults = {"api_key": "test-key", "model": "test-model"}
44
+ defaults.update(kwargs)
45
+ return AutocorrectConfig(**defaults)
46
+
47
+
48
+ # ── resolve_api_key ──────────────────────────────────────────────────────
49
+
50
+
51
+ def test_resolve_from_config():
52
+ cfg = _cfg(api_key="sk-from-config")
53
+ assert resolve_api_key(cfg) == "sk-from-config"
54
+
55
+
56
+ @patch.dict("os.environ", {"OPENROUTER_API_KEY": "sk-from-env"}, clear=False)
57
+ def test_resolve_from_env():
58
+ cfg = _cfg(api_key="")
59
+ assert resolve_api_key(cfg) == "sk-from-env"
60
+
61
+
62
+ @patch.dict("os.environ", {}, clear=True)
63
+ def test_resolve_empty():
64
+ cfg = _cfg(api_key="")
65
+ assert resolve_api_key(cfg) == ""
66
+
67
+
68
+ # ── chat ─────────────────────────────────────────────────────────────────
69
+
70
+
71
+ def test_chat_success():
72
+ conn = _fake_conn({"choices": [{"message": {"content": "Fixed text."}}]})
73
+ with patch("http.client.HTTPSConnection", return_value=conn):
74
+ result = chat(_cfg(), "system prompt", "user message")
75
+ assert result == "Fixed text."
76
+
77
+ path, headers, body = _request_kwargs(conn)
78
+ assert path == "/v1/chat/completions" or path.endswith("/chat/completions")
79
+ assert headers["Authorization"] == "Bearer test-key"
80
+ assert headers["Content-Type"] == "application/json"
81
+ assert body["model"] == "test-model"
82
+ assert body["messages"][0]["role"] == "system"
83
+ assert body["messages"][1]["role"] == "user"
84
+
85
+
86
+ def test_chat_timeout():
87
+ conn = MagicMock()
88
+ conn.request.side_effect = OSError("timed out")
89
+ with patch("http.client.HTTPSConnection", return_value=conn):
90
+ assert chat(_cfg(), "sys", "msg") is None
91
+
92
+
93
+ def test_chat_http_401():
94
+ conn = _fake_conn({"error": "invalid key"}, status=401)
95
+ with patch("http.client.HTTPSConnection", return_value=conn):
96
+ assert chat(_cfg(), "sys", "msg") is None
97
+
98
+
99
+ def test_chat_no_api_key():
100
+ cfg = _cfg(api_key="")
101
+ with patch.dict("os.environ", {}, clear=True):
102
+ assert chat(cfg, "sys", "msg") is None
103
+
104
+
105
+ def test_chat_malformed_response():
106
+ conn = _fake_conn({"unexpected": "format"})
107
+ with patch("http.client.HTTPSConnection", return_value=conn):
108
+ assert chat(_cfg(), "sys", "msg") is None
109
+
110
+
111
+ def test_chat_null_content_returns_none():
112
+ """Thinking models (Kimi K2.6 etc.) can return content=None — must not crash."""
113
+ conn = _fake_conn({"choices": [{"message": {"content": None}}]})
114
+ with patch("http.client.HTTPSConnection", return_value=conn):
115
+ assert chat(_cfg(), "sys", "msg") is None
116
+
117
+
118
+ def test_chat_falls_back_to_reasoning_field():
119
+ """When content is null but reasoning is present, use reasoning text."""
120
+ conn = _fake_conn({
121
+ "choices": [{"message": {"content": None, "reasoning": "the answer"}}],
122
+ })
123
+ with patch("http.client.HTTPSConnection", return_value=conn):
124
+ assert chat(_cfg(), "sys", "msg") == "the answer"
125
+
126
+
127
+ def test_chat_falls_back_to_reasoning_content_field():
128
+ """Some providers expose `reasoning_content` instead of `reasoning`."""
129
+ conn = _fake_conn({
130
+ "choices": [{"message": {
131
+ "content": None,
132
+ "reasoning_content": "thought-through answer",
133
+ }}],
134
+ })
135
+ with patch("http.client.HTTPSConnection", return_value=conn):
136
+ assert chat(_cfg(), "sys", "msg") == "thought-through answer"
137
+
138
+
139
+ def test_chat_anthropic_null_content_array():
140
+ """Anthropic native API: content=null shouldn't crash."""
141
+ conn = _fake_conn({"content": None})
142
+ with patch("http.client.HTTPSConnection", return_value=conn):
143
+ cfg = _cfg(base_url="https://api.anthropic.com/v1")
144
+ assert chat(cfg, "sys", "msg") is None
145
+
146
+
147
+ # ── check_api_key ────────────────────────────────────────────────────────
148
+
149
+
150
+ def test_check_valid_key():
151
+ conn = _fake_conn({"choices": [{"message": {"content": ""}}]})
152
+ with patch("http.client.HTTPSConnection", return_value=conn):
153
+ assert check_api_key(_cfg(), "sk-valid") is True
154
+
155
+
156
+ def test_check_invalid_key():
157
+ conn = _fake_conn({"error": "unauthorized"}, status=401)
158
+ with patch("http.client.HTTPSConnection", return_value=conn):
159
+ assert check_api_key(_cfg(), "sk-invalid") is False
160
+
161
+
162
+ def test_check_empty_key():
163
+ cfg = _cfg(api_key="")
164
+ with patch.dict("os.environ", {}, clear=True):
165
+ assert check_api_key(cfg) is False
166
+
167
+
168
+ # ── Anthropic native API ────────────────────────────────────────────────
169
+
170
+
171
+ def test_chat_anthropic_native():
172
+ conn = _fake_conn({"content": [{"type": "text", "text": "Fixed text."}]})
173
+ with patch("http.client.HTTPSConnection", return_value=conn):
174
+ cfg = _cfg(base_url="https://api.anthropic.com/v1")
175
+ result = chat(cfg, "system prompt", "user message")
176
+ assert result == "Fixed text."
177
+
178
+ path, headers, body = _request_kwargs(conn)
179
+ assert path.endswith("/messages")
180
+ assert headers["x-api-key"] == "test-key"
181
+ assert headers["anthropic-version"] == "2023-06-01"
182
+ assert "Authorization" not in headers
183
+ assert body["system"] == "system prompt"
184
+ assert body["messages"] == [{"role": "user", "content": "user message"}]
185
+
186
+
187
+ def test_check_api_key_anthropic():
188
+ conn = _fake_conn({"content": [{"type": "text", "text": ""}]})
189
+ with patch("http.client.HTTPSConnection", return_value=conn):
190
+ cfg = _cfg(base_url="https://api.anthropic.com/v1")
191
+ assert check_api_key(cfg, "sk-ant-test") is True
192
+ path, _, _ = _request_kwargs(conn)
193
+ assert path.endswith("/messages")
194
+
195
+
196
+ # ── detect_provider ─────────────────────────────────────────────────────
197
+
198
+
199
+ def test_detect_openrouter():
200
+ base_url, model = detect_provider("sk-or-abc123")
201
+ assert "openrouter" in base_url
202
+ assert "kimi" in model
203
+
204
+
205
+ def test_detect_anthropic():
206
+ base_url, model = detect_provider("sk-ant-abc123")
207
+ assert "anthropic.com" in base_url
208
+ assert "claude" in model
209
+
210
+
211
+ def test_detect_openai():
212
+ base_url, model = detect_provider("sk-proj-abc123")
213
+ assert "openai.com" in base_url
214
+
215
+
216
+ def test_detect_unknown_defaults_openrouter():
217
+ base_url, _ = detect_provider("unknown-key-format")
218
+ assert "openrouter" in base_url
219
+
220
+
221
+ # ── keep-alive connection pool ───────────────────────────────────────────
222
+
223
+
224
+ def test_post_json_reuses_connection():
225
+ conn = _fake_conn({"ok": 1})
226
+ with patch("http.client.HTTPSConnection", return_value=conn) as cls:
227
+ r1 = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
228
+ r2 = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
229
+ assert r1 == {"ok": 1} and r2 == {"ok": 1}
230
+ cls.assert_called_once() # second call reused the pooled connection
231
+ assert conn.request.call_count == 2
232
+
233
+
234
+ def test_post_json_retries_stale_pooled_connection():
235
+ stale = MagicMock()
236
+ stale.sock = None
237
+ stale.request.side_effect = http.client.BadStatusLine("") # server closed it
238
+ llm_api._CONN_POOL["https://api.example.com"] = stale
239
+ fresh = _fake_conn({"ok": 2})
240
+ with patch("http.client.HTTPSConnection", return_value=fresh):
241
+ r = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
242
+ assert r == {"ok": 2}
243
+ stale.close.assert_called_once()
244
+
245
+
246
+ def test_post_json_fresh_connection_error_propagates():
247
+ conn = MagicMock()
248
+ conn.request.side_effect = OSError("connection refused")
249
+ with patch("http.client.HTTPSConnection", return_value=conn):
250
+ with pytest.raises(OSError):
251
+ llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
252
+
253
+
254
+ def test_post_json_http_error_compatible():
255
+ conn = _fake_conn({"error": "bad key"}, status=401)
256
+ with patch("http.client.HTTPSConnection", return_value=conn):
257
+ with pytest.raises(urllib.error.HTTPError) as exc:
258
+ llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
259
+ assert exc.value.code == 401
260
+ assert b"bad key" in exc.value.read()
261
+ assert not llm_api._CONN_POOL # error connections are not pooled
@@ -867,3 +867,84 @@ class TestDecodeFailureSafety:
867
867
  np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
868
868
  ])
869
869
  assert _tail_ends_in_silence(with_pause, SR)
870
+
871
+
872
+ class TestFreezeCalibration:
873
+ def test_hot_noise_floor_pause_is_silence(self):
874
+ """Real-mic calibration: noise floor ~0.3x of tail RMS IS a pause."""
875
+ from voiceio.streaming import _tail_ends_in_silence
876
+ speech = np.full(int(SR * 2.5), 0.12, dtype=np.float32)
877
+ 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)
879
+
880
+ def test_speech_level_window_is_not_silence(self):
881
+ from voiceio.streaming import _tail_ends_in_silence
882
+ tail = np.full(SR * 3, 0.12, dtype=np.float32)
883
+ assert not _tail_ends_in_silence(tail, SR)
884
+
885
+
886
+ class TestDecodeBackpressure:
887
+ def _make_session(self):
888
+ recorder = MagicMock()
889
+ recorder.sample_rate = SR
890
+ transcriber = MagicMock()
891
+ transcriber.last_segments = []
892
+ s = StreamingSession(
893
+ transcriber=transcriber,
894
+ typer=MagicMock(spec=TyperBackend),
895
+ recorder=recorder,
896
+ )
897
+ return s, transcriber, recorder
898
+
899
+ def test_interim_skipped_while_behind(self):
900
+ """No new interim until new audio >= duration of the last decode."""
901
+ s, tr, rec = self._make_session()
902
+ s._last_decode_end = SR * 10
903
+ s._last_decode_secs = 8.0 # last decode took 8s (contended CPU)
904
+ rec.get_audio_so_far.return_value = _loud(13) # only 3s new audio
905
+ s._transcribe_and_apply()
906
+ tr.transcribe.assert_not_called()
907
+
908
+ def test_interim_runs_once_caught_up(self):
909
+ s, tr, rec = self._make_session()
910
+ s._last_decode_end = SR * 10
911
+ s._last_decode_secs = 8.0
912
+ rec.get_audio_so_far.return_value = _loud(19) # 9s new audio > 8s
913
+ tr.transcribe.return_value = "hello"
914
+ s._transcribe_and_apply()
915
+ tr.transcribe.assert_called_once()
916
+ assert s._last_decode_end == SR * 19
917
+
918
+ def test_final_never_skipped_by_backpressure(self):
919
+ s, tr, rec = self._make_session()
920
+ s._last_decode_end = SR * 10
921
+ s._last_decode_secs = 60.0
922
+ s._final_audio = _loud(11)
923
+ tr.transcribe.return_value = "hello"
924
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
925
+ tr.transcribe.assert_called_once()
926
+
927
+
928
+ class TestStaleInterimAfterStop:
929
+ def test_interim_completing_after_stop_is_discarded(self):
930
+ """A slow interim decode finishing post-stop must not touch the display."""
931
+ recorder = MagicMock()
932
+ recorder.sample_rate = SR
933
+ transcriber = MagicMock()
934
+ transcriber.last_segments = []
935
+ s = StreamingSession(
936
+ transcriber=transcriber,
937
+ typer=MagicMock(spec=StreamingTyper),
938
+ recorder=recorder,
939
+ )
940
+ s._typed_text = "Okay."
941
+ recorder.get_audio_so_far.return_value = _loud(8)
942
+
943
+ def slow_then_stopped(audio, final=False, context=None):
944
+ s._stop_event.set() # stop pressed while decode in flight
945
+ return "Okay, two things. One, is it"
946
+
947
+ transcriber.transcribe.side_effect = slow_then_stopped
948
+ s._transcribe_and_apply()
949
+ s._typer.update_preedit.assert_not_called()
950
+ assert s._typed_text == "Okay." # display state untouched
@@ -0,0 +1 @@
1
+ __version__ = "0.7.1"