python-voiceio 0.3.4__tar.gz → 0.3.6__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.3.4/python_voiceio.egg-info → python_voiceio-0.3.6}/PKG-INFO +1 -1
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/pyproject.toml +1 -1
- {python_voiceio-0.3.4 → python_voiceio-0.3.6/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_robustness.py +188 -0
- python_voiceio-0.3.6/voiceio/__init__.py +1 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/app.py +312 -3
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/service.py +19 -6
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/streaming.py +23 -1
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/clipboard.py +7 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/ibus.py +14 -2
- python_voiceio-0.3.4/voiceio/__init__.py +0 -1
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/LICENSE +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/README.md +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/setup.cfg +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_commands.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_config.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_corrections.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_fallback.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_health.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_hints.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_history.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_llm.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_numbers.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_platform.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_prompt.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_streaming.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_tts.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_vad.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/__main__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/backends.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/cli.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/commands.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/config.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/corrections.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/demo.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/feedback.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/health.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hints.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/history.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/llm.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/numbers.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/platform.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/prompt.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/recorder.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/vad.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/wizard.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.3.4 → python_voiceio-0.3.6}/voiceio/worker.py +0 -0
|
@@ -524,3 +524,191 @@ class TestTrayWatchdog:
|
|
|
524
524
|
with patch("voiceio.app.tray") as mock_tray:
|
|
525
525
|
vio._check_health()
|
|
526
526
|
mock_tray.is_alive.assert_not_called()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ===========================================================================
|
|
530
|
+
# 9. Boot-race and typer re-probe (regression tests for 0.3.6 fix)
|
|
531
|
+
# ===========================================================================
|
|
532
|
+
|
|
533
|
+
class TestBootRaceAndReProbe:
|
|
534
|
+
"""Tests for the fix where platform.detect's lru_cache froze a stale
|
|
535
|
+
display=unknown result at boot, defeating all re-probe logic."""
|
|
536
|
+
|
|
537
|
+
def test_redetect_clears_lru_cache(self):
|
|
538
|
+
"""_redetect_platform must call cache_clear so env changes take effect."""
|
|
539
|
+
from voiceio.app import _redetect_platform
|
|
540
|
+
from voiceio import platform as plat
|
|
541
|
+
|
|
542
|
+
# Warm the cache with display=unknown (simulating boot before env ready)
|
|
543
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
544
|
+
plat.detect.cache_clear()
|
|
545
|
+
p1 = plat.detect()
|
|
546
|
+
assert p1.display_server == "unknown"
|
|
547
|
+
|
|
548
|
+
# Env vars appear. Plain detect() would return cached unknown;
|
|
549
|
+
# _redetect_platform must clear cache and return the fresh values.
|
|
550
|
+
with patch.dict("os.environ", {
|
|
551
|
+
"XDG_SESSION_TYPE": "wayland",
|
|
552
|
+
"WAYLAND_DISPLAY": "wayland-0",
|
|
553
|
+
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
554
|
+
}):
|
|
555
|
+
p2 = _redetect_platform()
|
|
556
|
+
assert p2.display_server == "wayland"
|
|
557
|
+
assert p2.desktop == "gnome"
|
|
558
|
+
|
|
559
|
+
plat.detect.cache_clear()
|
|
560
|
+
|
|
561
|
+
def test_import_graphical_env_is_one_shot(self):
|
|
562
|
+
"""After all vars are present, _import_graphical_env becomes a no-op
|
|
563
|
+
(no subprocess every health-check cycle)."""
|
|
564
|
+
from voiceio import app as app_mod
|
|
565
|
+
from voiceio.app import _import_graphical_env
|
|
566
|
+
|
|
567
|
+
app_mod._graphical_env_complete = False
|
|
568
|
+
|
|
569
|
+
with patch.dict("os.environ", {
|
|
570
|
+
"DISPLAY": ":0",
|
|
571
|
+
"WAYLAND_DISPLAY": "wayland-0",
|
|
572
|
+
"XDG_SESSION_TYPE": "wayland",
|
|
573
|
+
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
574
|
+
"XDG_SESSION_DESKTOP": "gnome",
|
|
575
|
+
}):
|
|
576
|
+
with patch("voiceio.app.subprocess.check_output") as mock_sub:
|
|
577
|
+
_import_graphical_env()
|
|
578
|
+
assert app_mod._graphical_env_complete is True
|
|
579
|
+
mock_sub.assert_not_called() # all vars present → no subprocess
|
|
580
|
+
_import_graphical_env()
|
|
581
|
+
mock_sub.assert_not_called() # still no subprocess
|
|
582
|
+
|
|
583
|
+
app_mod._graphical_env_complete = False
|
|
584
|
+
|
|
585
|
+
def test_try_upgrade_typer_upgrades_from_clipboard_to_ibus(self):
|
|
586
|
+
"""When chain has ibus first and current is clipboard, upgrade."""
|
|
587
|
+
vio, _, _ = _make_vio()
|
|
588
|
+
vio.platform = MagicMock(display_server="wayland", desktop="gnome")
|
|
589
|
+
vio._typer = MagicMock()
|
|
590
|
+
vio._typer.name = "clipboard"
|
|
591
|
+
|
|
592
|
+
# Make the ClipboardTyper isinstance check false (we're using MagicMock)
|
|
593
|
+
better = MagicMock()
|
|
594
|
+
better.name = "ibus"
|
|
595
|
+
|
|
596
|
+
with patch("voiceio.app.typer_chain.resolve", return_value=[
|
|
597
|
+
("ibus", better, MagicMock(ok=True)),
|
|
598
|
+
("clipboard", vio._typer, MagicMock(ok=True)),
|
|
599
|
+
]), patch("voiceio.app.typer_chain.select", return_value=better), \
|
|
600
|
+
patch("voiceio.app.typer_chain._get_chain",
|
|
601
|
+
return_value=["ibus", "clipboard"]), \
|
|
602
|
+
patch.object(vio, "_ensure_ibus_engine") as mock_ensure:
|
|
603
|
+
result = vio._try_upgrade_typer(reason="test")
|
|
604
|
+
assert result is True
|
|
605
|
+
assert vio._typer is better
|
|
606
|
+
mock_ensure.assert_called_once()
|
|
607
|
+
|
|
608
|
+
def test_try_upgrade_typer_no_upgrade_when_already_best(self):
|
|
609
|
+
"""If already on the first entry in the chain, no upgrade."""
|
|
610
|
+
vio, _, _ = _make_vio()
|
|
611
|
+
vio.platform = MagicMock(display_server="wayland", desktop="gnome")
|
|
612
|
+
vio._typer = MagicMock()
|
|
613
|
+
vio._typer.name = "ibus"
|
|
614
|
+
|
|
615
|
+
with patch("voiceio.app.typer_chain._get_chain",
|
|
616
|
+
return_value=["ibus", "clipboard"]):
|
|
617
|
+
result = vio._try_upgrade_typer(reason="test")
|
|
618
|
+
assert result is False
|
|
619
|
+
|
|
620
|
+
def test_try_upgrade_typer_no_upgrade_when_unknown_platform(self):
|
|
621
|
+
"""Regression: with display=unknown the chain is ['clipboard'], and
|
|
622
|
+
upgrade should early-return False (this was silent and defeated the
|
|
623
|
+
health loop before the lru_cache fix)."""
|
|
624
|
+
vio, _, _ = _make_vio()
|
|
625
|
+
vio.platform = MagicMock(display_server="unknown", desktop="unknown")
|
|
626
|
+
vio._typer = MagicMock()
|
|
627
|
+
vio._typer.name = "clipboard"
|
|
628
|
+
|
|
629
|
+
with patch("voiceio.app.typer_chain._get_chain",
|
|
630
|
+
return_value=["clipboard"]):
|
|
631
|
+
result = vio._try_upgrade_typer(reason="test")
|
|
632
|
+
assert result is False
|
|
633
|
+
|
|
634
|
+
def test_boot_race_healed_by_health_check(self):
|
|
635
|
+
"""Simulate: __init__ ran with display=unknown and picked clipboard.
|
|
636
|
+
Later, env vars become available and health check should upgrade."""
|
|
637
|
+
from voiceio import app as app_mod
|
|
638
|
+
from voiceio import platform as plat
|
|
639
|
+
|
|
640
|
+
vio, _, _ = _make_vio()
|
|
641
|
+
# Simulate the stale state as if we started at boot
|
|
642
|
+
vio.platform = MagicMock(display_server="unknown", desktop="unknown")
|
|
643
|
+
vio._typer = MagicMock()
|
|
644
|
+
vio._typer.name = "clipboard"
|
|
645
|
+
vio.recorder.stream_health = MagicMock(return_value=(True, ""))
|
|
646
|
+
vio.transcriber.is_worker_alive = MagicMock(return_value=True)
|
|
647
|
+
vio.cfg.tray.enabled = False
|
|
648
|
+
|
|
649
|
+
# Env arrives; health check should re-import, re-detect, and upgrade.
|
|
650
|
+
better = MagicMock()
|
|
651
|
+
better.name = "ibus"
|
|
652
|
+
better.probe.return_value = MagicMock(ok=True)
|
|
653
|
+
|
|
654
|
+
# Force env import to go through; reset one-shot flag
|
|
655
|
+
app_mod._graphical_env_complete = False
|
|
656
|
+
|
|
657
|
+
fresh_platform = MagicMock(display_server="wayland", desktop="gnome")
|
|
658
|
+
|
|
659
|
+
with patch.dict("os.environ", {
|
|
660
|
+
"DISPLAY": ":0",
|
|
661
|
+
"WAYLAND_DISPLAY": "wayland-0",
|
|
662
|
+
"XDG_SESSION_TYPE": "wayland",
|
|
663
|
+
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
664
|
+
"XDG_SESSION_DESKTOP": "gnome",
|
|
665
|
+
}), patch("voiceio.app._redetect_platform", return_value=fresh_platform), \
|
|
666
|
+
patch("voiceio.app.typer_chain._get_chain",
|
|
667
|
+
return_value=["ibus", "clipboard"]), \
|
|
668
|
+
patch("voiceio.app.typer_chain.resolve", return_value=[
|
|
669
|
+
("ibus", better, MagicMock(ok=True)),
|
|
670
|
+
]), patch("voiceio.app.typer_chain.select", return_value=better), \
|
|
671
|
+
patch.object(vio, "_ensure_ibus_engine"):
|
|
672
|
+
vio._check_health()
|
|
673
|
+
|
|
674
|
+
assert vio._typer is better
|
|
675
|
+
assert vio.platform is fresh_platform
|
|
676
|
+
plat.detect.cache_clear()
|
|
677
|
+
app_mod._graphical_env_complete = False
|
|
678
|
+
|
|
679
|
+
def test_on_typer_broken_defers_until_idle(self):
|
|
680
|
+
"""Mid-recording, _on_typer_broken must NOT hot-swap the typer."""
|
|
681
|
+
from voiceio.app import _State
|
|
682
|
+
|
|
683
|
+
vio, _, _ = _make_vio()
|
|
684
|
+
vio._state = _State.RECORDING
|
|
685
|
+
original_typer = vio._typer
|
|
686
|
+
|
|
687
|
+
# Call the handler directly (no thread) to check it respects state.
|
|
688
|
+
# The real handler kicks off a thread; we test the internal method.
|
|
689
|
+
with patch.object(vio, "_try_upgrade_typer") as mock_upgrade:
|
|
690
|
+
# Simulate the thread having already waited: state is still RECORDING
|
|
691
|
+
# Force deadline-past by calling with state still RECORDING
|
|
692
|
+
# We invoke _deferred_typer_upgrade with a very short timeout
|
|
693
|
+
# by mocking time.monotonic and time.sleep.
|
|
694
|
+
with patch("voiceio.app.time.monotonic",
|
|
695
|
+
side_effect=[0, 100, 100, 100]), \
|
|
696
|
+
patch("voiceio.app.time.sleep"):
|
|
697
|
+
vio._deferred_typer_upgrade()
|
|
698
|
+
mock_upgrade.assert_not_called()
|
|
699
|
+
assert vio._typer is original_typer
|
|
700
|
+
|
|
701
|
+
def test_on_typer_broken_runs_when_idle(self):
|
|
702
|
+
"""Once IDLE is reached, the deferred upgrade runs."""
|
|
703
|
+
from voiceio.app import _State
|
|
704
|
+
|
|
705
|
+
vio, _, _ = _make_vio()
|
|
706
|
+
vio._state = _State.IDLE
|
|
707
|
+
|
|
708
|
+
with patch.object(vio, "_try_upgrade_typer") as mock_upgrade, \
|
|
709
|
+
patch("voiceio.app._import_graphical_env"), \
|
|
710
|
+
patch("voiceio.app._redetect_platform",
|
|
711
|
+
return_value=vio.platform), \
|
|
712
|
+
patch("voiceio.app.time.sleep"):
|
|
713
|
+
vio._deferred_typer_upgrade()
|
|
714
|
+
mock_upgrade.assert_called_once_with(reason="streaming-failure")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.6"
|
|
@@ -29,6 +29,66 @@ if TYPE_CHECKING:
|
|
|
29
29
|
log = logging.getLogger("voiceio")
|
|
30
30
|
_DEBOUNCE_SECS = 0.3
|
|
31
31
|
|
|
32
|
+
# Env vars needed for clipboard/tray/typing on graphical sessions.
|
|
33
|
+
# XDG_CURRENT_DESKTOP is required for is_gnome() detection, which gates
|
|
34
|
+
# IBus input source configuration.
|
|
35
|
+
_GRAPHICAL_ENV_VARS = (
|
|
36
|
+
"DISPLAY",
|
|
37
|
+
"WAYLAND_DISPLAY",
|
|
38
|
+
"XDG_SESSION_TYPE",
|
|
39
|
+
"XDG_CURRENT_DESKTOP",
|
|
40
|
+
"XDG_SESSION_DESKTOP",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _redetect_platform():
|
|
45
|
+
"""Clear the cached Platform and re-run detection.
|
|
46
|
+
|
|
47
|
+
plat.detect() is @lru_cache'd, so the first call freezes the result.
|
|
48
|
+
When voiceio starts before the desktop session has exported its env
|
|
49
|
+
vars, that first call returns display='unknown' and we'd be stuck
|
|
50
|
+
with that forever. Always clear the cache before re-detecting.
|
|
51
|
+
"""
|
|
52
|
+
plat.detect.cache_clear()
|
|
53
|
+
return plat.detect()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_graphical_env_complete = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _import_graphical_env() -> None:
|
|
60
|
+
"""Pull graphical session env vars from the systemd user manager.
|
|
61
|
+
|
|
62
|
+
When started as a systemd user service, the process may launch before
|
|
63
|
+
the desktop session imports DISPLAY/WAYLAND_DISPLAY. This queries
|
|
64
|
+
``systemctl --user show-environment`` to pick them up after the fact.
|
|
65
|
+
|
|
66
|
+
Once all expected vars are present, sets ``_graphical_env_complete``
|
|
67
|
+
and becomes a free no-op so callers in the health loop don't spawn
|
|
68
|
+
a subprocess every 10s.
|
|
69
|
+
"""
|
|
70
|
+
global _graphical_env_complete
|
|
71
|
+
if _graphical_env_complete:
|
|
72
|
+
return
|
|
73
|
+
missing = [v for v in _GRAPHICAL_ENV_VARS if v not in os.environ]
|
|
74
|
+
if not missing:
|
|
75
|
+
_graphical_env_complete = True
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
out = subprocess.check_output(
|
|
79
|
+
["systemctl", "--user", "show-environment"],
|
|
80
|
+
text=True, timeout=3,
|
|
81
|
+
)
|
|
82
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
83
|
+
return
|
|
84
|
+
for line in out.splitlines():
|
|
85
|
+
key, _, val = line.partition("=")
|
|
86
|
+
if key in missing and val:
|
|
87
|
+
os.environ[key] = val
|
|
88
|
+
log.info("Imported %s=%s from systemd user env", key, val)
|
|
89
|
+
if not [v for v in _GRAPHICAL_ENV_VARS if v not in os.environ]:
|
|
90
|
+
_graphical_env_complete = True
|
|
91
|
+
|
|
32
92
|
|
|
33
93
|
_HEALTH_CHECK_INTERVAL = 10 # seconds between health checks
|
|
34
94
|
|
|
@@ -43,7 +103,19 @@ class _State(enum.Enum):
|
|
|
43
103
|
class VoiceIO:
|
|
44
104
|
def __init__(self, cfg: config.Config):
|
|
45
105
|
self.cfg = cfg
|
|
46
|
-
|
|
106
|
+
|
|
107
|
+
# Import graphical env vars early so typer/platform detection
|
|
108
|
+
# sees DISPLAY, WAYLAND_DISPLAY, XDG_SESSION_TYPE, XDG_CURRENT_DESKTOP
|
|
109
|
+
# even when started as a systemd user service before the desktop
|
|
110
|
+
# session has exported them.
|
|
111
|
+
_import_graphical_env()
|
|
112
|
+
|
|
113
|
+
# Fresh detection (clears lru_cache in case anything warmed it)
|
|
114
|
+
self.platform = _redetect_platform()
|
|
115
|
+
log.info(
|
|
116
|
+
"Platform: display=%s desktop=%s",
|
|
117
|
+
self.platform.display_server, self.platform.desktop,
|
|
118
|
+
)
|
|
47
119
|
|
|
48
120
|
# Select backends
|
|
49
121
|
self._hotkey = hotkey_chain.select(self.platform, cfg.hotkey.backend)
|
|
@@ -208,6 +280,7 @@ class VoiceIO:
|
|
|
208
280
|
commands=self._command_processor,
|
|
209
281
|
corrections=self._corrections,
|
|
210
282
|
llm=self._llm,
|
|
283
|
+
on_typer_broken=self._on_typer_broken,
|
|
211
284
|
)
|
|
212
285
|
self._session.start()
|
|
213
286
|
log.info("Recording... press [%s] again to stop", self.cfg.hotkey.key)
|
|
@@ -395,6 +468,108 @@ class VoiceIO:
|
|
|
395
468
|
except RuntimeError:
|
|
396
469
|
log.error("No working typer backend available")
|
|
397
470
|
|
|
471
|
+
def _on_typer_broken(self) -> None:
|
|
472
|
+
"""Called by streaming session when typer fails repeatedly.
|
|
473
|
+
|
|
474
|
+
Defers the re-probe+upgrade to a background thread that waits
|
|
475
|
+
for the state to reach IDLE. We cannot hot-swap mid-recording:
|
|
476
|
+
the streaming session tracks ``_typed_text`` in terms of the old
|
|
477
|
+
typer's behavior (char-level for clipboard vs preedit for ibus),
|
|
478
|
+
and mid-stream swapping would leave stale or duplicated chars.
|
|
479
|
+
The current recording is already broken — accept that, fix it
|
|
480
|
+
before the next one.
|
|
481
|
+
"""
|
|
482
|
+
threading.Thread(
|
|
483
|
+
target=self._deferred_typer_upgrade,
|
|
484
|
+
daemon=True, name="typer-upgrade",
|
|
485
|
+
).start()
|
|
486
|
+
|
|
487
|
+
def _deferred_typer_upgrade(self) -> None:
|
|
488
|
+
"""Wait for IDLE state, then re-detect platform and upgrade typer."""
|
|
489
|
+
# Wait up to 30s for recording/finalizing to complete
|
|
490
|
+
deadline = time.monotonic() + 30
|
|
491
|
+
while time.monotonic() < deadline:
|
|
492
|
+
if self._state == _State.IDLE or self._shutdown.is_set():
|
|
493
|
+
break
|
|
494
|
+
time.sleep(0.5)
|
|
495
|
+
if self._shutdown.is_set():
|
|
496
|
+
return
|
|
497
|
+
with self._hotkey_lock:
|
|
498
|
+
if self._state != _State.IDLE:
|
|
499
|
+
log.debug("Typer upgrade abandoned: state=%s after 30s", self._state)
|
|
500
|
+
return
|
|
501
|
+
_import_graphical_env()
|
|
502
|
+
self.platform = _redetect_platform()
|
|
503
|
+
log.info(
|
|
504
|
+
"Typer broken: re-detected platform: display=%s desktop=%s",
|
|
505
|
+
self.platform.display_server, self.platform.desktop,
|
|
506
|
+
)
|
|
507
|
+
self._try_upgrade_typer(reason="streaming-failure")
|
|
508
|
+
|
|
509
|
+
def _try_upgrade_typer(self, reason: str = "") -> bool:
|
|
510
|
+
"""Try to switch to a better typer backend.
|
|
511
|
+
|
|
512
|
+
Called on resume, after env import, or after repeated typer failures.
|
|
513
|
+
If the current typer is a fallback (e.g. clipboard) and a preferred
|
|
514
|
+
backend (e.g. ibus) is now available, switch to it.
|
|
515
|
+
|
|
516
|
+
Returns True if typer was upgraded.
|
|
517
|
+
"""
|
|
518
|
+
chain = typer_chain._get_chain(self.platform)
|
|
519
|
+
current_idx = chain.index(self._typer.name) if self._typer.name in chain else len(chain)
|
|
520
|
+
log.debug(
|
|
521
|
+
"Upgrade attempt (%s): current=%s (idx %d) chain=%s",
|
|
522
|
+
reason, self._typer.name, current_idx, chain,
|
|
523
|
+
)
|
|
524
|
+
if current_idx == 0:
|
|
525
|
+
log.debug("Upgrade (%s) skipped: already on best backend in chain", reason)
|
|
526
|
+
return False # already on the best backend
|
|
527
|
+
|
|
528
|
+
# Reset clipboard tool cache so re-probe sees updated env
|
|
529
|
+
from voiceio.typers.clipboard import ClipboardTyper
|
|
530
|
+
if isinstance(self._typer, ClipboardTyper):
|
|
531
|
+
self._typer.reset_tools()
|
|
532
|
+
|
|
533
|
+
# Log all probe results so we can diagnose why upgrade failed
|
|
534
|
+
try:
|
|
535
|
+
results = typer_chain.resolve(self.platform, self.cfg.output.method)
|
|
536
|
+
except Exception:
|
|
537
|
+
log.exception("Upgrade (%s): typer resolve failed", reason)
|
|
538
|
+
return False
|
|
539
|
+
for name, _backend, probe in results:
|
|
540
|
+
status = "OK" if probe.ok else f"FAIL: {probe.reason}"
|
|
541
|
+
log.info("Upgrade (%s): probe %s -> %s", reason, name, status)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
new_typer = typer_chain.select(self.platform, self.cfg.output.method)
|
|
545
|
+
except RuntimeError as e:
|
|
546
|
+
log.warning("Upgrade (%s) select failed: %s", reason, e)
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
new_idx = chain.index(new_typer.name) if new_typer.name in chain else len(chain)
|
|
550
|
+
if new_idx < current_idx:
|
|
551
|
+
old_name = self._typer.name
|
|
552
|
+
self._typer = new_typer
|
|
553
|
+
log.info("Typer upgraded: %s -> %s (%s)", old_name, new_typer.name, reason)
|
|
554
|
+
if new_typer.name == "ibus" and self._engine_proc is None:
|
|
555
|
+
try:
|
|
556
|
+
self._ensure_ibus_engine()
|
|
557
|
+
except Exception:
|
|
558
|
+
log.exception("Upgrade (%s): failed to start IBus engine", reason)
|
|
559
|
+
return True
|
|
560
|
+
|
|
561
|
+
log.info(
|
|
562
|
+
"Upgrade (%s): no better backend available (stayed on %s)",
|
|
563
|
+
reason, new_typer.name,
|
|
564
|
+
)
|
|
565
|
+
# Even if same backend, re-resolve tools (e.g. clipboard switching
|
|
566
|
+
# from xclip to wl-copy after env vars change)
|
|
567
|
+
if isinstance(self._typer, ClipboardTyper):
|
|
568
|
+
self._typer._resolve_tools()
|
|
569
|
+
log.debug("Clipboard typer tools re-resolved (%s)", reason)
|
|
570
|
+
|
|
571
|
+
return False
|
|
572
|
+
|
|
398
573
|
# ── IBus engine lifecycle ───────────────────────────────────────────
|
|
399
574
|
|
|
400
575
|
def _ensure_ibus_engine(self) -> None:
|
|
@@ -473,6 +648,34 @@ class VoiceIO:
|
|
|
473
648
|
self._set_gnome_input_source_index(0)
|
|
474
649
|
log.info("VoiceIO IBus engine ready (dormant until recording)")
|
|
475
650
|
|
|
651
|
+
def _reactivate_ibus_if_stale(self) -> None:
|
|
652
|
+
"""Re-activate IBus engine if it lost registration (e.g. after hibernate).
|
|
653
|
+
|
|
654
|
+
After suspend/hibernate, the IBus daemon may forget about our engine.
|
|
655
|
+
Check by querying the current active engine; if it's not 'voiceio',
|
|
656
|
+
re-run ``ibus engine voiceio`` to re-register.
|
|
657
|
+
"""
|
|
658
|
+
from voiceio.typers.ibus import _ibus_env
|
|
659
|
+
try:
|
|
660
|
+
result = subprocess.run(
|
|
661
|
+
["ibus", "engine"], capture_output=True, text=True,
|
|
662
|
+
timeout=3, env=_ibus_env(),
|
|
663
|
+
)
|
|
664
|
+
current = result.stdout.strip() if result.returncode == 0 else ""
|
|
665
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
666
|
+
return
|
|
667
|
+
if current == "voiceio":
|
|
668
|
+
return # still registered, nothing to do
|
|
669
|
+
log.warning("IBus engine stale (current=%r), re-activating", current)
|
|
670
|
+
try:
|
|
671
|
+
subprocess.run(
|
|
672
|
+
["ibus", "engine", "voiceio"],
|
|
673
|
+
capture_output=True, timeout=5, env=_ibus_env(),
|
|
674
|
+
)
|
|
675
|
+
log.info("IBus engine re-activated")
|
|
676
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
677
|
+
log.warning("IBus re-activation failed: %s", e)
|
|
678
|
+
|
|
476
679
|
def _wait_for_ibus(self, chain: list[str]) -> TyperBackend | None:
|
|
477
680
|
"""Wait for IBus daemon to become available and switch to IBus typer.
|
|
478
681
|
|
|
@@ -584,17 +787,79 @@ class VoiceIO:
|
|
|
584
787
|
|
|
585
788
|
# ── Health watchdog ─────────────────────────────────────────────
|
|
586
789
|
|
|
790
|
+
# If the gap between health checks exceeds this, we probably resumed
|
|
791
|
+
# from suspend/hibernate and should re-probe everything.
|
|
792
|
+
_RESUME_THRESHOLD = 30 # seconds
|
|
793
|
+
|
|
587
794
|
def _health_loop(self) -> None:
|
|
588
795
|
"""Periodic health check: transcriber worker, IBus engine, audio stream."""
|
|
796
|
+
last_check = time.monotonic()
|
|
589
797
|
while not self._shutdown.is_set():
|
|
590
798
|
self._shutdown.wait(_HEALTH_CHECK_INTERVAL)
|
|
591
799
|
if self._shutdown.is_set():
|
|
592
800
|
break
|
|
801
|
+
|
|
802
|
+
now = time.monotonic()
|
|
803
|
+
gap = now - last_check
|
|
804
|
+
last_check = now
|
|
805
|
+
|
|
593
806
|
try:
|
|
807
|
+
if gap > self._RESUME_THRESHOLD:
|
|
808
|
+
log.info("System resume detected (%.0fs gap), re-probing all backends", gap)
|
|
809
|
+
self._on_resume()
|
|
594
810
|
self._check_health()
|
|
595
811
|
except Exception:
|
|
596
812
|
log.debug("Health check error", exc_info=True)
|
|
597
813
|
|
|
814
|
+
def _on_resume(self) -> None:
|
|
815
|
+
"""Re-probe all backends after system suspend/hibernate.
|
|
816
|
+
|
|
817
|
+
Sleep/hibernate breaks connections to system services (IBus, audio,
|
|
818
|
+
tray D-Bus, ydotoold) across all platforms. Instead of catching each
|
|
819
|
+
failure individually, do a single sweep to restore everything.
|
|
820
|
+
"""
|
|
821
|
+
_import_graphical_env()
|
|
822
|
+
|
|
823
|
+
# Re-detect platform now that env vars may have been refreshed
|
|
824
|
+
self.platform = _redetect_platform()
|
|
825
|
+
|
|
826
|
+
# Audio: reopen stream (device may have changed or died)
|
|
827
|
+
try:
|
|
828
|
+
self.recorder.reopen_stream()
|
|
829
|
+
log.info("Resume: audio stream reopened")
|
|
830
|
+
except Exception:
|
|
831
|
+
log.warning("Resume: audio stream reopen failed", exc_info=True)
|
|
832
|
+
|
|
833
|
+
# Typer: re-probe if on a fallback backend (e.g. clipboard instead
|
|
834
|
+
# of ibus) — the preferred backend may be available now.
|
|
835
|
+
self._try_upgrade_typer(reason="resume")
|
|
836
|
+
|
|
837
|
+
# IBus: re-activate engine registration
|
|
838
|
+
if self._typer.name == "ibus" and self._engine_proc is not None:
|
|
839
|
+
if self._engine_proc.poll() is not None:
|
|
840
|
+
# Engine process died during sleep
|
|
841
|
+
self._engine_proc = None
|
|
842
|
+
try:
|
|
843
|
+
self._ensure_ibus_engine()
|
|
844
|
+
log.info("Resume: IBus engine restarted")
|
|
845
|
+
except Exception:
|
|
846
|
+
log.exception("Resume: IBus engine restart failed")
|
|
847
|
+
else:
|
|
848
|
+
self._reactivate_ibus_if_stale()
|
|
849
|
+
|
|
850
|
+
# Tray: restart if subprocess died
|
|
851
|
+
if self.cfg.tray.enabled and not tray.is_alive():
|
|
852
|
+
log.info("Resume: restarting tray")
|
|
853
|
+
tray.restart(self.on_hotkey)
|
|
854
|
+
|
|
855
|
+
# Transcriber: ensure worker is alive
|
|
856
|
+
if not self.transcriber.is_worker_alive():
|
|
857
|
+
try:
|
|
858
|
+
self.transcriber._ensure_worker()
|
|
859
|
+
log.info("Resume: transcriber worker restarted")
|
|
860
|
+
except RuntimeError:
|
|
861
|
+
log.error("Resume: transcriber worker failed")
|
|
862
|
+
|
|
598
863
|
def _check_health(self) -> None:
|
|
599
864
|
"""Run one health check cycle."""
|
|
600
865
|
# Check transcriber worker
|
|
@@ -644,7 +909,31 @@ class VoiceIO:
|
|
|
644
909
|
log.warning("Tray subprocess died, restarting")
|
|
645
910
|
tray.restart(self.on_hotkey)
|
|
646
911
|
|
|
647
|
-
# Check
|
|
912
|
+
# Check typer: if on a fallback, try to upgrade to preferred backend.
|
|
913
|
+
# Refresh env + platform first in case we started before the desktop
|
|
914
|
+
# session had exported DISPLAY/WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP.
|
|
915
|
+
_import_graphical_env()
|
|
916
|
+
old_desktop = self.platform.desktop
|
|
917
|
+
self.platform = _redetect_platform()
|
|
918
|
+
if self.platform.desktop != old_desktop:
|
|
919
|
+
log.info(
|
|
920
|
+
"Platform refreshed: display=%s desktop=%s (was desktop=%s)",
|
|
921
|
+
self.platform.display_server, self.platform.desktop, old_desktop,
|
|
922
|
+
)
|
|
923
|
+
chain = typer_chain._get_chain(self.platform)
|
|
924
|
+
current_idx = chain.index(self._typer.name) if self._typer.name in chain else len(chain)
|
|
925
|
+
if current_idx > 0:
|
|
926
|
+
self._try_upgrade_typer(reason="health-check")
|
|
927
|
+
|
|
928
|
+
# Check typer: re-probe if current backend is broken
|
|
929
|
+
probe = self._typer.probe()
|
|
930
|
+
if not probe.ok:
|
|
931
|
+
log.warning("Typer '%s' probe failed: %s — re-selecting", self._typer.name, probe.reason)
|
|
932
|
+
_import_graphical_env()
|
|
933
|
+
self.platform = _redetect_platform()
|
|
934
|
+
self._try_upgrade_typer(reason="probe-failed")
|
|
935
|
+
|
|
936
|
+
# Check IBus engine (restart if died, re-activate if stale after resume)
|
|
648
937
|
if self._typer.name == "ibus" and self._engine_proc is not None:
|
|
649
938
|
if self._engine_proc.poll() is not None:
|
|
650
939
|
log.warning("IBus engine process died (rc=%d), restarting",
|
|
@@ -655,12 +944,30 @@ class VoiceIO:
|
|
|
655
944
|
log.info("IBus engine recovered")
|
|
656
945
|
except Exception:
|
|
657
946
|
log.exception("IBus engine recovery failed")
|
|
947
|
+
elif self._state == _State.IDLE:
|
|
948
|
+
# Engine alive but may have lost IBus registration after
|
|
949
|
+
# hibernate/suspend. Re-activate so preedit works next time.
|
|
950
|
+
self._reactivate_ibus_if_stale()
|
|
658
951
|
|
|
659
952
|
# ── Main loop ───────────────────────────────────────────────────────
|
|
660
953
|
|
|
661
954
|
def run(self) -> None:
|
|
662
955
|
from voiceio.config import PID_PATH, LOG_DIR
|
|
663
956
|
|
|
957
|
+
# Refresh env and platform in case the desktop session exported
|
|
958
|
+
# vars between __init__ and run() (e.g. boot race with user@.service).
|
|
959
|
+
_import_graphical_env()
|
|
960
|
+
old_platform = self.platform
|
|
961
|
+
self.platform = _redetect_platform()
|
|
962
|
+
if (self.platform.display_server != old_platform.display_server
|
|
963
|
+
or self.platform.desktop != old_platform.desktop):
|
|
964
|
+
log.info(
|
|
965
|
+
"Platform changed since init: display=%s desktop=%s (was %s/%s), re-selecting typer",
|
|
966
|
+
self.platform.display_server, self.platform.desktop,
|
|
967
|
+
old_platform.display_server, old_platform.desktop,
|
|
968
|
+
)
|
|
969
|
+
self._try_upgrade_typer(reason="run-init")
|
|
970
|
+
|
|
664
971
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
665
972
|
self._pid_fd = open(PID_PATH, "w")
|
|
666
973
|
try:
|
|
@@ -679,7 +986,9 @@ class VoiceIO:
|
|
|
679
986
|
else:
|
|
680
987
|
# IBus daemon may not be ready at startup (race with graphical
|
|
681
988
|
# session). If IBus is in the preferred chain but wasn't selected,
|
|
682
|
-
# wait briefly and re-probe.
|
|
989
|
+
# wait briefly and re-probe. Re-fetch the chain after env refresh
|
|
990
|
+
# above so we don't use a stale "clipboard-only" chain from when
|
|
991
|
+
# platform=unknown.
|
|
683
992
|
chain = typer_chain._get_chain(self.platform)
|
|
684
993
|
if "ibus" in chain and self._typer.name != "ibus":
|
|
685
994
|
self._typer = self._wait_for_ibus(chain) or self._typer
|
|
@@ -264,25 +264,38 @@ def install_symlinks() -> list[str]:
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def _add_local_bin_to_path() -> None:
|
|
267
|
-
"""Add ~/.local/bin to PATH
|
|
267
|
+
"""Add ~/.local/bin to PATH in the user's active shell rc file."""
|
|
268
268
|
global _PATH_HINT_ADDED
|
|
269
269
|
line = '\nexport PATH="$HOME/.local/bin:$PATH"\n'
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
|
|
271
|
+
# Detect the user's actual shell and target its rc file.
|
|
272
|
+
# .profile is sourced by login shells only — interactive terminals
|
|
273
|
+
# (especially zsh on Ubuntu) may never read it.
|
|
274
|
+
shell = os.environ.get("SHELL", "")
|
|
275
|
+
if "zsh" in shell:
|
|
276
|
+
targets = [".zshrc", ".profile"]
|
|
277
|
+
elif "bash" in shell:
|
|
278
|
+
targets = [".bashrc", ".profile"]
|
|
279
|
+
else:
|
|
280
|
+
targets = [".zshrc", ".bashrc", ".profile"]
|
|
281
|
+
|
|
282
|
+
for rc_name in targets:
|
|
272
283
|
rc = Path.home() / rc_name
|
|
273
284
|
if rc.exists():
|
|
274
285
|
content = rc.read_text()
|
|
275
286
|
if ".local/bin" in content:
|
|
276
|
-
|
|
287
|
+
continue # already in this file, check next
|
|
277
288
|
rc.write_text(content + line)
|
|
278
289
|
log.info("Added ~/.local/bin to PATH in %s", rc)
|
|
279
290
|
_PATH_HINT_ADDED = True
|
|
280
291
|
return
|
|
281
|
-
|
|
282
|
-
rc
|
|
292
|
+
|
|
293
|
+
# No existing rc found — create the shell-appropriate one
|
|
294
|
+
rc = Path.home() / (targets[0] if targets else ".profile")
|
|
283
295
|
rc.write_text(line)
|
|
284
296
|
log.info("Created %s with PATH entry", rc)
|
|
285
297
|
_PATH_HINT_ADDED = True
|
|
298
|
+
_PATH_HINT_ADDED = True
|
|
286
299
|
|
|
287
300
|
|
|
288
301
|
def symlinks_installed() -> bool:
|
|
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import logging
|
|
5
5
|
import re
|
|
6
|
+
import subprocess
|
|
6
7
|
import threading
|
|
7
8
|
import time
|
|
8
|
-
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import TYPE_CHECKING, Callable
|
|
9
10
|
|
|
10
11
|
from voiceio.transcriber import TRANSCRIBE_TIMEOUT
|
|
11
12
|
from voiceio.typers.base import StreamingTyper
|
|
@@ -48,6 +49,9 @@ def _word_match_len(old_words: list[str], new_words: list[str]) -> int:
|
|
|
48
49
|
return count
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
_TYPER_FAIL_THRESHOLD = 3 # consecutive failures before signalling re-probe
|
|
53
|
+
|
|
54
|
+
|
|
51
55
|
class StreamingSession:
|
|
52
56
|
"""Manages one streaming transcription cycle.
|
|
53
57
|
|
|
@@ -75,6 +79,7 @@ class StreamingSession:
|
|
|
75
79
|
commands: CommandProcessor | None = None,
|
|
76
80
|
corrections: CorrectionDict | None = None,
|
|
77
81
|
llm: LLMProcessor | None = None,
|
|
82
|
+
on_typer_broken: Callable[[], None] | None = None,
|
|
78
83
|
):
|
|
79
84
|
self._transcriber = transcriber
|
|
80
85
|
self._typer = typer
|
|
@@ -87,6 +92,9 @@ class StreamingSession:
|
|
|
87
92
|
self._commands = commands
|
|
88
93
|
self._corrections = corrections
|
|
89
94
|
self._llm = llm
|
|
95
|
+
self._on_typer_broken = on_typer_broken
|
|
96
|
+
self._typer_fail_count = 0
|
|
97
|
+
self._typer_broken_signalled = False
|
|
90
98
|
self._typed_text = ""
|
|
91
99
|
self._pending = threading.Event()
|
|
92
100
|
self._stop_event = threading.Event()
|
|
@@ -137,6 +145,20 @@ class StreamingSession:
|
|
|
137
145
|
break
|
|
138
146
|
try:
|
|
139
147
|
self._transcribe_and_apply()
|
|
148
|
+
except subprocess.CalledProcessError:
|
|
149
|
+
self._typer_fail_count += 1
|
|
150
|
+
if (self._typer_fail_count >= _TYPER_FAIL_THRESHOLD
|
|
151
|
+
and not self._typer_broken_signalled):
|
|
152
|
+
self._typer_broken_signalled = True
|
|
153
|
+
log.warning(
|
|
154
|
+
"Typer '%s' failed %d times in streaming, requesting re-probe",
|
|
155
|
+
self._typer.name, self._typer_fail_count,
|
|
156
|
+
)
|
|
157
|
+
if self._on_typer_broken:
|
|
158
|
+
self._on_typer_broken()
|
|
159
|
+
elif self._typer_fail_count < _TYPER_FAIL_THRESHOLD:
|
|
160
|
+
log.exception("Streaming typer error (%d/%d)",
|
|
161
|
+
self._typer_fail_count, _TYPER_FAIL_THRESHOLD)
|
|
140
162
|
except Exception:
|
|
141
163
|
log.exception("Streaming transcribe/apply error (non-fatal)")
|
|
142
164
|
|
|
@@ -65,6 +65,13 @@ class ClipboardTyper:
|
|
|
65
65
|
self._pynput_kb = Controller()
|
|
66
66
|
return self._pynput_kb
|
|
67
67
|
|
|
68
|
+
def reset_tools(self) -> None:
|
|
69
|
+
"""Clear cached tool resolution so next probe re-detects."""
|
|
70
|
+
self._copy_cmd = None
|
|
71
|
+
self._paste_tool = None
|
|
72
|
+
self._delete_tool = None
|
|
73
|
+
self._tools_resolved = False
|
|
74
|
+
|
|
68
75
|
def probe(self) -> ProbeResult:
|
|
69
76
|
self._resolve_tools()
|
|
70
77
|
if self._copy_cmd is None or (
|
|
@@ -384,10 +384,22 @@ class IBusTyper:
|
|
|
384
384
|
except (OSError, socket.timeout):
|
|
385
385
|
return True # assume focused on error (safe default: no extra paste)
|
|
386
386
|
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _ydotoold_running() -> bool:
|
|
389
|
+
"""Check if ydotoold daemon is running (required for reliable ydotool)."""
|
|
390
|
+
try:
|
|
391
|
+
result = subprocess.run(
|
|
392
|
+
["pgrep", "-x", "ydotoold"],
|
|
393
|
+
capture_output=True, timeout=2,
|
|
394
|
+
)
|
|
395
|
+
return result.returncode == 0
|
|
396
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
397
|
+
return False
|
|
398
|
+
|
|
387
399
|
def _simulate_paste(self) -> None:
|
|
388
400
|
"""Simulate Ctrl+V to paste clipboard into non-IBus apps (terminals)."""
|
|
389
401
|
try:
|
|
390
|
-
if self._ydotool:
|
|
402
|
+
if self._ydotool and self._ydotoold_running():
|
|
391
403
|
# ydotool key scancodes: 29=LCtrl, 47=V
|
|
392
404
|
subprocess.run(
|
|
393
405
|
[self._ydotool, "key", "29:1", "47:1", "47:0", "29:0"],
|
|
@@ -399,7 +411,7 @@ class IBusTyper:
|
|
|
399
411
|
capture_output=True, timeout=3,
|
|
400
412
|
)
|
|
401
413
|
else:
|
|
402
|
-
log.debug("No paste tool available (
|
|
414
|
+
log.debug("No paste tool available (ydotoold not running, wtype not found)")
|
|
403
415
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
404
416
|
log.debug("Paste simulation failed: %s", e)
|
|
405
417
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|