python-voiceio 0.3.5__tar.gz → 0.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {python_voiceio-0.3.5/python_voiceio.egg-info → python_voiceio-0.3.7}/PKG-INFO +1 -1
  2. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/pyproject.toml +2 -2
  3. {python_voiceio-0.3.5 → python_voiceio-0.3.7/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/entry_points.txt +1 -1
  5. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_robustness.py +198 -0
  6. python_voiceio-0.3.7/voiceio/__init__.py +1 -0
  7. python_voiceio-0.3.7/voiceio/__main__.py +3 -0
  8. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/app.py +226 -2
  9. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/cli.py +59 -0
  10. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/service.py +19 -6
  11. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/streaming.py +23 -1
  12. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/clipboard.py +7 -0
  13. python_voiceio-0.3.5/voiceio/__init__.py +0 -1
  14. python_voiceio-0.3.5/voiceio/__main__.py +0 -3
  15. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/LICENSE +0 -0
  16. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/README.md +0 -0
  17. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/SOURCES.txt +0 -0
  18. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/dependency_links.txt +0 -0
  19. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/requires.txt +0 -0
  20. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/top_level.txt +0 -0
  21. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/setup.cfg +0 -0
  22. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_app_wiring.py +0 -0
  23. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_autocorrect.py +0 -0
  24. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_backend_probes.py +0 -0
  25. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_clipboard_read.py +0 -0
  26. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_commands.py +0 -0
  27. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_config.py +0 -0
  28. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_corrections.py +0 -0
  29. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_fallback.py +0 -0
  30. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_health.py +0 -0
  31. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_hints.py +0 -0
  32. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_history.py +0 -0
  33. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_ibus_typer.py +0 -0
  34. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_llm.py +0 -0
  35. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_llm_api.py +0 -0
  36. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_numbers.py +0 -0
  37. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_platform.py +0 -0
  38. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_postprocess.py +0 -0
  39. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_prebuffer.py +0 -0
  40. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_prompt.py +0 -0
  41. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_recorder_integration.py +0 -0
  42. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_streaming.py +0 -0
  43. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_transcriber.py +0 -0
  44. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_tts.py +0 -0
  45. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_vad.py +0 -0
  46. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_vocabulary.py +0 -0
  47. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_wordfreq.py +0 -0
  48. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/autocorrect.py +0 -0
  49. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/backends.py +0 -0
  50. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/clipboard_read.py +0 -0
  51. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/commands.py +0 -0
  52. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/config.py +0 -0
  53. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/corrections.py +0 -0
  54. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/demo.py +0 -0
  55. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/feedback.py +0 -0
  56. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/health.py +0 -0
  57. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hints.py +0 -0
  58. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/history.py +0 -0
  59. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/__init__.py +0 -0
  60. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/base.py +0 -0
  61. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/chain.py +0 -0
  62. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/evdev.py +0 -0
  63. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/pynput_backend.py +0 -0
  64. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/socket_backend.py +0 -0
  65. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/ibus/__init__.py +0 -0
  66. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/ibus/engine.py +0 -0
  67. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/llm.py +0 -0
  68. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/llm_api.py +0 -0
  69. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/models/__init__.py +0 -0
  70. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/models/silero_vad.onnx +0 -0
  71. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/numbers.py +0 -0
  72. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/pidlock.py +0 -0
  73. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/platform.py +0 -0
  74. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/postprocess.py +0 -0
  75. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/prompt.py +0 -0
  76. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/recorder.py +0 -0
  77. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/__init__.py +0 -0
  78. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/commit.wav +0 -0
  79. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/start.wav +0 -0
  80. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/stop.wav +0 -0
  81. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/transcriber.py +0 -0
  82. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/__init__.py +0 -0
  83. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_icons.py +0 -0
  84. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_indicator.py +0 -0
  85. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_pystray.py +0 -0
  86. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/__init__.py +0 -0
  87. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/base.py +0 -0
  88. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/chain.py +0 -0
  89. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/edge_engine.py +0 -0
  90. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/espeak.py +0 -0
  91. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/piper_engine.py +0 -0
  92. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/player.py +0 -0
  93. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/__init__.py +0 -0
  94. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/base.py +0 -0
  95. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/chain.py +0 -0
  96. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/ibus.py +0 -0
  97. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/pynput_type.py +0 -0
  98. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/wtype.py +0 -0
  99. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/xdotool.py +0 -0
  100. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/ydotool.py +0 -0
  101. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/vad.py +0 -0
  102. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/vocabulary.py +0 -0
  103. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/wizard.py +0 -0
  104. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/wordfreq.py +0 -0
  105. {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.3.5"
7
+ version = "0.3.7"
8
8
  description = "Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -50,7 +50,7 @@ Issues = "https://github.com/Hugo0/voiceio/issues"
50
50
  Changelog = "https://github.com/Hugo0/voiceio/releases"
51
51
 
52
52
  [project.scripts]
53
- voiceio = "voiceio.cli:main"
53
+ voiceio = "voiceio.cli:_entry_point"
54
54
  # Legacy aliases (prefer: voiceio toggle/doctor/setup/test)
55
55
  voiceio-toggle = "voiceio.cli:_cmd_toggle"
56
56
  voiceio-doctor = "voiceio.cli:_cmd_doctor_legacy"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  [console_scripts]
2
- voiceio = voiceio.cli:main
2
+ voiceio = voiceio.cli:_entry_point
3
3
  voiceio-doctor = voiceio.cli:_cmd_doctor_legacy
4
4
  voiceio-setup = voiceio.cli:_cmd_setup
5
5
  voiceio-test = voiceio.cli:_cmd_test
@@ -1,11 +1,13 @@
1
1
  """Tests for robustness features: stream health, tray watchdog, audio backoff."""
2
2
  from __future__ import annotations
3
3
 
4
+ import sys
4
5
  import threading
5
6
  import time
6
7
  from unittest.mock import MagicMock, patch
7
8
 
8
9
  import numpy as np
10
+ import pytest
9
11
  from voiceio.config import Config
10
12
 
11
13
 
@@ -524,3 +526,199 @@ class TestTrayWatchdog:
524
526
  with patch("voiceio.app.tray") as mock_tray:
525
527
  vio._check_health()
526
528
  mock_tray.is_alive.assert_not_called()
529
+
530
+
531
+ # ===========================================================================
532
+ # 9. Boot-race and typer re-probe (regression tests for 0.3.6 fix)
533
+ # ===========================================================================
534
+
535
+ _linux_only = pytest.mark.skipif(
536
+ not sys.platform.startswith("linux"),
537
+ reason="Boot-race scenario is Linux-specific "
538
+ "(macOS/Windows detect display server from sys.platform)",
539
+ )
540
+
541
+
542
+ class TestBootRaceAndReProbe:
543
+ """Tests for the fix where platform.detect's lru_cache froze a stale
544
+ display=unknown result at boot, defeating all re-probe logic."""
545
+
546
+ @_linux_only
547
+ def test_redetect_clears_lru_cache(self):
548
+ """_redetect_platform must call cache_clear so env changes take effect."""
549
+ from voiceio.app import _redetect_platform
550
+ from voiceio import platform as plat
551
+
552
+ # Warm the cache with display=unknown (simulating boot before env ready)
553
+ with patch.dict("os.environ", {}, clear=True):
554
+ plat.detect.cache_clear()
555
+ p1 = plat.detect()
556
+ assert p1.display_server == "unknown"
557
+
558
+ # Env vars appear. Plain detect() would return cached unknown;
559
+ # _redetect_platform must clear cache and return the fresh values.
560
+ with patch.dict("os.environ", {
561
+ "XDG_SESSION_TYPE": "wayland",
562
+ "WAYLAND_DISPLAY": "wayland-0",
563
+ "XDG_CURRENT_DESKTOP": "GNOME",
564
+ }):
565
+ p2 = _redetect_platform()
566
+ assert p2.display_server == "wayland"
567
+ assert p2.desktop == "gnome"
568
+
569
+ plat.detect.cache_clear()
570
+
571
+ def test_import_graphical_env_is_one_shot(self):
572
+ """After all vars are present, _import_graphical_env becomes a no-op
573
+ (no subprocess every health-check cycle)."""
574
+ from voiceio import app as app_mod
575
+ from voiceio.app import _import_graphical_env
576
+
577
+ app_mod._graphical_env_complete = False
578
+
579
+ with patch.dict("os.environ", {
580
+ "DISPLAY": ":0",
581
+ "WAYLAND_DISPLAY": "wayland-0",
582
+ "XDG_SESSION_TYPE": "wayland",
583
+ "XDG_CURRENT_DESKTOP": "GNOME",
584
+ "XDG_SESSION_DESKTOP": "gnome",
585
+ }):
586
+ with patch("voiceio.app.subprocess.check_output") as mock_sub:
587
+ _import_graphical_env()
588
+ assert app_mod._graphical_env_complete is True
589
+ mock_sub.assert_not_called() # all vars present → no subprocess
590
+ _import_graphical_env()
591
+ mock_sub.assert_not_called() # still no subprocess
592
+
593
+ app_mod._graphical_env_complete = False
594
+
595
+ def test_try_upgrade_typer_upgrades_from_clipboard_to_ibus(self):
596
+ """When chain has ibus first and current is clipboard, upgrade."""
597
+ vio, _, _ = _make_vio()
598
+ vio.platform = MagicMock(display_server="wayland", desktop="gnome")
599
+ vio._typer = MagicMock()
600
+ vio._typer.name = "clipboard"
601
+
602
+ # Make the ClipboardTyper isinstance check false (we're using MagicMock)
603
+ better = MagicMock()
604
+ better.name = "ibus"
605
+
606
+ with patch("voiceio.app.typer_chain.resolve", return_value=[
607
+ ("ibus", better, MagicMock(ok=True)),
608
+ ("clipboard", vio._typer, MagicMock(ok=True)),
609
+ ]), patch("voiceio.app.typer_chain.select", return_value=better), \
610
+ patch("voiceio.app.typer_chain._get_chain",
611
+ return_value=["ibus", "clipboard"]), \
612
+ patch.object(vio, "_ensure_ibus_engine") as mock_ensure:
613
+ result = vio._try_upgrade_typer(reason="test")
614
+ assert result is True
615
+ assert vio._typer is better
616
+ mock_ensure.assert_called_once()
617
+
618
+ def test_try_upgrade_typer_no_upgrade_when_already_best(self):
619
+ """If already on the first entry in the chain, no upgrade."""
620
+ vio, _, _ = _make_vio()
621
+ vio.platform = MagicMock(display_server="wayland", desktop="gnome")
622
+ vio._typer = MagicMock()
623
+ vio._typer.name = "ibus"
624
+
625
+ with patch("voiceio.app.typer_chain._get_chain",
626
+ return_value=["ibus", "clipboard"]):
627
+ result = vio._try_upgrade_typer(reason="test")
628
+ assert result is False
629
+
630
+ def test_try_upgrade_typer_no_upgrade_when_unknown_platform(self):
631
+ """Regression: with display=unknown the chain is ['clipboard'], and
632
+ upgrade should early-return False (this was silent and defeated the
633
+ health loop before the lru_cache fix)."""
634
+ vio, _, _ = _make_vio()
635
+ vio.platform = MagicMock(display_server="unknown", desktop="unknown")
636
+ vio._typer = MagicMock()
637
+ vio._typer.name = "clipboard"
638
+
639
+ with patch("voiceio.app.typer_chain._get_chain",
640
+ return_value=["clipboard"]):
641
+ result = vio._try_upgrade_typer(reason="test")
642
+ assert result is False
643
+
644
+ def test_boot_race_healed_by_health_check(self):
645
+ """Simulate: __init__ ran with display=unknown and picked clipboard.
646
+ Later, env vars become available and health check should upgrade."""
647
+ from voiceio import app as app_mod
648
+ from voiceio import platform as plat
649
+
650
+ vio, _, _ = _make_vio()
651
+ # Simulate the stale state as if we started at boot
652
+ vio.platform = MagicMock(display_server="unknown", desktop="unknown")
653
+ vio._typer = MagicMock()
654
+ vio._typer.name = "clipboard"
655
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
656
+ vio.transcriber.is_worker_alive = MagicMock(return_value=True)
657
+ vio.cfg.tray.enabled = False
658
+
659
+ # Env arrives; health check should re-import, re-detect, and upgrade.
660
+ better = MagicMock()
661
+ better.name = "ibus"
662
+ better.probe.return_value = MagicMock(ok=True)
663
+
664
+ # Force env import to go through; reset one-shot flag
665
+ app_mod._graphical_env_complete = False
666
+
667
+ fresh_platform = MagicMock(display_server="wayland", desktop="gnome")
668
+
669
+ with patch.dict("os.environ", {
670
+ "DISPLAY": ":0",
671
+ "WAYLAND_DISPLAY": "wayland-0",
672
+ "XDG_SESSION_TYPE": "wayland",
673
+ "XDG_CURRENT_DESKTOP": "GNOME",
674
+ "XDG_SESSION_DESKTOP": "gnome",
675
+ }), patch("voiceio.app._redetect_platform", return_value=fresh_platform), \
676
+ patch("voiceio.app.typer_chain._get_chain",
677
+ return_value=["ibus", "clipboard"]), \
678
+ patch("voiceio.app.typer_chain.resolve", return_value=[
679
+ ("ibus", better, MagicMock(ok=True)),
680
+ ]), patch("voiceio.app.typer_chain.select", return_value=better), \
681
+ patch.object(vio, "_ensure_ibus_engine"):
682
+ vio._check_health()
683
+
684
+ assert vio._typer is better
685
+ assert vio.platform is fresh_platform
686
+ plat.detect.cache_clear()
687
+ app_mod._graphical_env_complete = False
688
+
689
+ def test_on_typer_broken_defers_until_idle(self):
690
+ """Mid-recording, _on_typer_broken must NOT hot-swap the typer."""
691
+ from voiceio.app import _State
692
+
693
+ vio, _, _ = _make_vio()
694
+ vio._state = _State.RECORDING
695
+ original_typer = vio._typer
696
+
697
+ # Call the handler directly (no thread) to check it respects state.
698
+ # The real handler kicks off a thread; we test the internal method.
699
+ with patch.object(vio, "_try_upgrade_typer") as mock_upgrade:
700
+ # Simulate the thread having already waited: state is still RECORDING
701
+ # Force deadline-past by calling with state still RECORDING
702
+ # We invoke _deferred_typer_upgrade with a very short timeout
703
+ # by mocking time.monotonic and time.sleep.
704
+ with patch("voiceio.app.time.monotonic",
705
+ side_effect=[0, 100, 100, 100]), \
706
+ patch("voiceio.app.time.sleep"):
707
+ vio._deferred_typer_upgrade()
708
+ mock_upgrade.assert_not_called()
709
+ assert vio._typer is original_typer
710
+
711
+ def test_on_typer_broken_runs_when_idle(self):
712
+ """Once IDLE is reached, the deferred upgrade runs."""
713
+ from voiceio.app import _State
714
+
715
+ vio, _, _ = _make_vio()
716
+ vio._state = _State.IDLE
717
+
718
+ with patch.object(vio, "_try_upgrade_typer") as mock_upgrade, \
719
+ patch("voiceio.app._import_graphical_env"), \
720
+ patch("voiceio.app._redetect_platform",
721
+ return_value=vio.platform), \
722
+ patch("voiceio.app.time.sleep"):
723
+ vio._deferred_typer_upgrade()
724
+ mock_upgrade.assert_called_once_with(reason="streaming-failure")
@@ -0,0 +1 @@
1
+ __version__ = "0.3.7"
@@ -0,0 +1,3 @@
1
+ from voiceio.cli import _entry_point
2
+
3
+ _entry_point()
@@ -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
- self.platform = plat.detect()
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:
@@ -643,6 +818,11 @@ class VoiceIO:
643
818
  tray D-Bus, ydotoold) across all platforms. Instead of catching each
644
819
  failure individually, do a single sweep to restore everything.
645
820
  """
821
+ _import_graphical_env()
822
+
823
+ # Re-detect platform now that env vars may have been refreshed
824
+ self.platform = _redetect_platform()
825
+
646
826
  # Audio: reopen stream (device may have changed or died)
647
827
  try:
648
828
  self.recorder.reopen_stream()
@@ -650,6 +830,10 @@ class VoiceIO:
650
830
  except Exception:
651
831
  log.warning("Resume: audio stream reopen failed", exc_info=True)
652
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
+
653
837
  # IBus: re-activate engine registration
654
838
  if self._typer.name == "ibus" and self._engine_proc is not None:
655
839
  if self._engine_proc.poll() is not None:
@@ -725,6 +909,30 @@ class VoiceIO:
725
909
  log.warning("Tray subprocess died, restarting")
726
910
  tray.restart(self.on_hotkey)
727
911
 
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
+
728
936
  # Check IBus engine (restart if died, re-activate if stale after resume)
729
937
  if self._typer.name == "ibus" and self._engine_proc is not None:
730
938
  if self._engine_proc.poll() is not None:
@@ -746,6 +954,20 @@ class VoiceIO:
746
954
  def run(self) -> None:
747
955
  from voiceio.config import PID_PATH, LOG_DIR
748
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
+
749
971
  LOG_DIR.mkdir(parents=True, exist_ok=True)
750
972
  self._pid_fd = open(PID_PATH, "w")
751
973
  try:
@@ -764,7 +986,9 @@ class VoiceIO:
764
986
  else:
765
987
  # IBus daemon may not be ready at startup (race with graphical
766
988
  # session). If IBus is in the preferred chain but wasn't selected,
767
- # 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.
768
992
  chain = typer_chain._get_chain(self.platform)
769
993
  if "ibus" in chain and self._typer.name != "ibus":
770
994
  self._typer = self._wait_for_ibus(chain) or self._typer
@@ -1092,3 +1092,62 @@ def _cmd_doctor_legacy() -> None:
1092
1092
  parser = argparse.ArgumentParser(prog="voiceio-doctor")
1093
1093
  parser.add_argument("--fix", action="store_true", help="Attempt to auto-fix issues")
1094
1094
  _cmd_doctor(parser.parse_args())
1095
+
1096
+
1097
+ def _entry_point() -> None:
1098
+ """PyInstaller entry point.
1099
+
1100
+ On Linux/macOS the console_scripts wrapper that setuptools generates
1101
+ from ``project.scripts`` calls ``main()`` directly, so module-level
1102
+ code here never runs. On Windows we ship a PyInstaller bundle that
1103
+ runs ``cli.py`` as ``__main__`` — without this wrapper, the exe would
1104
+ load all definitions and exit silently (which is exactly what users
1105
+ reported: "installed, clicked, nothing happened").
1106
+
1107
+ This wrapper also catches any unhandled exception and writes it to
1108
+ ``crash.log`` before exiting, so a crash before logging is configured
1109
+ still leaves a diagnostic trail. On Windows consoles we pause so the
1110
+ cmd.exe window stays open long enough to read the error.
1111
+ """
1112
+ try:
1113
+ main()
1114
+ except SystemExit:
1115
+ raise
1116
+ except BaseException:
1117
+ import traceback
1118
+ tb = traceback.format_exc()
1119
+ # Best-effort crash log: write to the standard log dir, and also
1120
+ # stderr. Use a fresh import path in case config import itself
1121
+ # was the thing that crashed.
1122
+ try:
1123
+ import os
1124
+ from pathlib import Path
1125
+ if sys.platform == "win32":
1126
+ log_dir = Path(os.environ.get("LOCALAPPDATA", Path.home())) / "voiceio" / "logs"
1127
+ else:
1128
+ log_dir = Path.home() / ".local" / "state" / "voiceio"
1129
+ log_dir.mkdir(parents=True, exist_ok=True)
1130
+ crash_path = log_dir / "crash.log"
1131
+ with open(crash_path, "a", encoding="utf-8") as f:
1132
+ import datetime
1133
+ f.write(f"\n===== {datetime.datetime.now().isoformat()} =====\n")
1134
+ f.write(f"argv: {sys.argv}\n")
1135
+ f.write(f"platform: {sys.platform}\n")
1136
+ f.write(tb)
1137
+ print(f"\n[voiceio crashed — wrote traceback to {crash_path}]",
1138
+ file=sys.stderr)
1139
+ except Exception:
1140
+ pass # absolute last-resort: nothing we can do
1141
+ print(tb, file=sys.stderr)
1142
+ # On Windows, keep the console window open so the user can read
1143
+ # the error instead of watching cmd.exe flash and close.
1144
+ if sys.platform == "win32" and sys.stdin is not None and sys.stdin.isatty():
1145
+ try:
1146
+ input("\nPress Enter to close...")
1147
+ except (EOFError, KeyboardInterrupt):
1148
+ pass
1149
+ sys.exit(1)
1150
+
1151
+
1152
+ if __name__ == "__main__":
1153
+ _entry_point()
@@ -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 via shell profile (for macOS etc.)."""
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
- # Try .zshrc first (macOS default), then .bashrc
271
- for rc_name in (".zshrc", ".bashrc", ".profile"):
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
- return # already there
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
- # No shell rc found, create .profile
282
- rc = Path.home() / ".profile"
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 (
@@ -1 +0,0 @@
1
- __version__ = "0.3.5"
@@ -1,3 +0,0 @@
1
- from voiceio.cli import main
2
-
3
- main()
File without changes
File without changes
File without changes