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.
- {python_voiceio-0.6.0/python_voiceio.egg-info → python_voiceio-0.7.1}/PKG-INFO +1 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/pyproject.toml +1 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_app_wiring.py +125 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_clipboard_read.py +30 -2
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_ibus_typer.py +4 -7
- python_voiceio-0.7.1/tests/test_llm_api.py +261 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_streaming.py +81 -0
- python_voiceio-0.7.1/voiceio/__init__.py +1 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/app.py +136 -27
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/clipboard_read.py +18 -2
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/config.py +6 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/engine.py +4 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/llm_api.py +60 -11
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/streaming.py +22 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/ibus.py +8 -16
- python_voiceio-0.6.0/tests/test_llm_api.py +0 -220
- python_voiceio-0.6.0/voiceio/__init__.py +0 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/LICENSE +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/README.md +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/setup.cfg +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_audit.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_cli.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_commands.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_config.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_corrections.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_fallback.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_health.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_hints.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_history.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_llm.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_numbers.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_platform.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_prompt.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_retention.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_robustness.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_tts.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_vad.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_wizard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/__main__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/audit.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/backends.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/cli.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/commands.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/consent.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/corrections.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/demo.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/feedback.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/health.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hints.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/history.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/llm.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/numbers.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/platform.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/prompt.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/recorder.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/retention.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/service.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/vad.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/wizard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.1}/voiceio/worker.py +0 -0
|
@@ -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",
|
|
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
|
-
|
|
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
|
-
|
|
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"
|