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.
- {python_voiceio-0.7.0/python_voiceio.egg-info → python_voiceio-0.8.0}/PKG-INFO +1 -1
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/pyproject.toml +1 -1
- {python_voiceio-0.7.0 → python_voiceio-0.8.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_app_wiring.py +125 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_clipboard_read.py +30 -2
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_ibus_typer.py +4 -7
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_streaming.py +29 -21
- python_voiceio-0.8.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/app.py +136 -27
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/clipboard_read.py +18 -2
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/config.py +6 -4
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/engine.py +4 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/streaming.py +78 -31
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/ibus.py +8 -16
- python_voiceio-0.7.0/voiceio/__init__.py +0 -1
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/LICENSE +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/README.md +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/setup.cfg +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_config.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_health.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_history.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_retention.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_robustness.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_wizard.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/health.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/history.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/recorder.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/retention.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/service.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/wizard.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.7.0 → python_voiceio-0.8.0}/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):
|
|
@@ -678,15 +678,21 @@ class TestSegmentFreeze:
|
|
|
678
678
|
)
|
|
679
679
|
return session, transcriber, recorder
|
|
680
680
|
|
|
681
|
-
def
|
|
681
|
+
def test_freeze_cuts_at_interior_pause(self):
|
|
682
682
|
s, tr, rec = self._make_session(freeze_secs=2.0)
|
|
683
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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"]
|
|
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
|
|
860
|
-
from voiceio.streaming import
|
|
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 —
|
|
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
|
|
865
|
-
# Same quiet mic with a real
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
881
|
-
from voiceio.streaming import
|
|
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
|
|
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
|
-
|
|
981
|
-
|
|
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(
|
|
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:
|
|
1220
|
-
#
|
|
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
|
-
|
|
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.
|
|
1299
|
-
#
|
|
1300
|
-
#
|
|
1301
|
-
#
|
|
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
|
-
|
|
1305
|
-
self._engine_proc.returncode)
|
|
1397
|
+
rc = self._engine_proc.returncode
|
|
1306
1398
|
self._engine_proc = None
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
log.
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
|
1317
|
-
#
|
|
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(),
|
|
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
|
|
89
|
-
# recording, so interim passes and the
|
|
90
|
-
# the short remaining tail.
|
|
91
|
-
|
|
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):
|