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.
- {python_voiceio-0.3.5/python_voiceio.egg-info → python_voiceio-0.3.7}/PKG-INFO +1 -1
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/pyproject.toml +2 -2
- {python_voiceio-0.3.5 → python_voiceio-0.3.7/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/entry_points.txt +1 -1
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_robustness.py +198 -0
- python_voiceio-0.3.7/voiceio/__init__.py +1 -0
- python_voiceio-0.3.7/voiceio/__main__.py +3 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/app.py +226 -2
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/cli.py +59 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/service.py +19 -6
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/streaming.py +23 -1
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/clipboard.py +7 -0
- python_voiceio-0.3.5/voiceio/__init__.py +0 -1
- python_voiceio-0.3.5/voiceio/__main__.py +0 -3
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/LICENSE +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/README.md +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/setup.cfg +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_commands.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_config.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_corrections.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_fallback.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_health.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_hints.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_history.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_llm.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_numbers.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_platform.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_prompt.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_streaming.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_tts.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_vad.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/backends.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/commands.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/config.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/corrections.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/demo.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/feedback.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/health.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hints.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/history.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/llm.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/numbers.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/platform.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/prompt.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/recorder.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/vad.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/wizard.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.3.5 → python_voiceio-0.3.7}/voiceio/worker.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-voiceio"
|
|
7
|
-
version = "0.3.
|
|
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:
|
|
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,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"
|
|
@@ -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:
|
|
@@ -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
|
|
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 (
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.5"
|
|
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
|