python-voiceio 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {python_voiceio-0.4.0/python_voiceio.egg-info → python_voiceio-0.5.0}/PKG-INFO +14 -6
  2. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/README.md +9 -5
  3. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/pyproject.toml +5 -1
  4. {python_voiceio-0.4.0 → python_voiceio-0.5.0/python_voiceio.egg-info}/PKG-INFO +14 -6
  5. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/python_voiceio.egg-info/SOURCES.txt +6 -0
  6. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/python_voiceio.egg-info/requires.txt +5 -0
  7. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_app_wiring.py +52 -1
  8. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_clipboard_read.py +54 -0
  9. python_voiceio-0.5.0/tests/test_concurrency_lockdown.py +254 -0
  10. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_config.py +13 -0
  11. python_voiceio-0.5.0/tests/test_ibus_pending.py +38 -0
  12. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_ibus_typer.py +57 -0
  13. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_prebuffer.py +46 -0
  14. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_robustness.py +53 -0
  15. python_voiceio-0.5.0/tests/test_security_hardening.py +238 -0
  16. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_streaming.py +170 -0
  17. python_voiceio-0.5.0/tests/test_transcriber.py +132 -0
  18. python_voiceio-0.5.0/tests/test_wizard.py +230 -0
  19. python_voiceio-0.5.0/voiceio/__init__.py +1 -0
  20. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/app.py +289 -80
  21. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/cli.py +148 -30
  22. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/clipboard_read.py +47 -1
  23. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/config.py +129 -0
  24. python_voiceio-0.5.0/voiceio/consent.py +57 -0
  25. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/demo.py +2 -2
  26. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/health.py +13 -4
  27. python_voiceio-0.5.0/voiceio/history.py +181 -0
  28. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/ibus/engine.py +18 -8
  29. python_voiceio-0.5.0/voiceio/ibus/pending.py +43 -0
  30. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/llm_api.py +37 -0
  31. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/platform.py +121 -0
  32. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/postcorrect.py +7 -0
  33. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/recorder.py +26 -12
  34. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/retention.py +13 -1
  35. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/streaming.py +91 -5
  36. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/transcriber.py +38 -4
  37. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/ibus.py +49 -8
  38. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/ydotool.py +40 -2
  39. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/wizard.py +433 -80
  40. python_voiceio-0.4.0/tests/test_transcriber.py +0 -69
  41. python_voiceio-0.4.0/voiceio/__init__.py +0 -1
  42. python_voiceio-0.4.0/voiceio/history.py +0 -88
  43. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/LICENSE +0 -0
  44. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  45. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  46. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/python_voiceio.egg-info/top_level.txt +0 -0
  47. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/setup.cfg +0 -0
  48. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_adjudicate.py +0 -0
  49. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_audio_quality.py +0 -0
  50. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_audit.py +0 -0
  51. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_autocorrect.py +0 -0
  52. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_autocorrect_state.py +0 -0
  53. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_backend_probes.py +0 -0
  54. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_cli.py +0 -0
  55. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_commands.py +0 -0
  56. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_correct_batch.py +0 -0
  57. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_corrections.py +0 -0
  58. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_fallback.py +0 -0
  59. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_health.py +0 -0
  60. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_hints.py +0 -0
  61. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_history.py +0 -0
  62. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_llm.py +0 -0
  63. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_llm_api.py +0 -0
  64. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_numbers.py +0 -0
  65. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_platform.py +0 -0
  66. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_postcorrect.py +0 -0
  67. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_postprocess.py +0 -0
  68. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_prompt.py +0 -0
  69. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_recorder_integration.py +0 -0
  70. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_retention.py +0 -0
  71. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_snapshots.py +0 -0
  72. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_tts.py +0 -0
  73. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_vad.py +0 -0
  74. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_vocabulary.py +0 -0
  75. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/tests/test_wordfreq.py +0 -0
  76. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/__main__.py +0 -0
  77. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/audit.py +0 -0
  78. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/autocorrect.py +0 -0
  79. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/autocorrect_state.py +0 -0
  80. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/backends.py +0 -0
  81. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/commands.py +0 -0
  82. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/corrections.py +0 -0
  83. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/feedback.py +0 -0
  84. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hints.py +0 -0
  85. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/__init__.py +0 -0
  86. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/base.py +0 -0
  87. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/chain.py +0 -0
  88. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/evdev.py +0 -0
  89. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  90. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/hotkeys/socket_backend.py +0 -0
  91. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/ibus/__init__.py +0 -0
  92. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/llm.py +0 -0
  93. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/models/__init__.py +0 -0
  94. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/models/silero_vad.onnx +0 -0
  95. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/numbers.py +0 -0
  96. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/pidlock.py +0 -0
  97. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/postprocess.py +0 -0
  98. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/prompt.py +0 -0
  99. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/service.py +0 -0
  100. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/snapshots.py +0 -0
  101. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/sounds/__init__.py +0 -0
  102. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/sounds/commit.wav +0 -0
  103. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/sounds/start.wav +0 -0
  104. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/sounds/stop.wav +0 -0
  105. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tray/__init__.py +0 -0
  106. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tray/_icons.py +0 -0
  107. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tray/_indicator.py +0 -0
  108. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tray/_pystray.py +0 -0
  109. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/__init__.py +0 -0
  110. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/base.py +0 -0
  111. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/chain.py +0 -0
  112. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/edge_engine.py +0 -0
  113. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/espeak.py +0 -0
  114. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/piper_engine.py +0 -0
  115. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/tts/player.py +0 -0
  116. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/__init__.py +0 -0
  117. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/base.py +0 -0
  118. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/chain.py +0 -0
  119. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/clipboard.py +0 -0
  120. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/pynput_type.py +0 -0
  121. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/wtype.py +0 -0
  122. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/typers/xdotool.py +0 -0
  123. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/vad.py +0 -0
  124. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/vocabulary.py +0 -0
  125. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/wordfreq.py +0 -0
  126. {python_voiceio-0.4.0 → python_voiceio-0.5.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -40,6 +40,10 @@ Requires-Dist: edge-tts>=6.1.0; extra == "tts-cloud"
40
40
  Provides-Extra: dev
41
41
  Requires-Dist: pytest>=7.0; extra == "dev"
42
42
  Requires-Dist: pytest-mock; extra == "dev"
43
+ Provides-Extra: linux
44
+ Requires-Dist: pystray>=0.19; extra == "linux"
45
+ Requires-Dist: Pillow>=10.0; extra == "linux"
46
+ Requires-Dist: piper-tts>=1.2.0; extra == "linux"
43
47
  Dynamic: license-file
44
48
 
45
49
  # voiceio
@@ -74,8 +78,9 @@ Open-source, sovereign voice input/output. Your speech is transcribed on your ow
74
78
  ## Quick start
75
79
 
76
80
  ```bash
77
- # 1. Install system dependencies (Ubuntu/Debian)
78
- sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi python3-dev portaudio19-dev
81
+ # 1. Install system dependencies (Ubuntu/Debian). build-essential + python3-dev
82
+ # are needed to compile evdev (no prebuilt wheel); portaudio for the mic.
83
+ sudo apt install pipx build-essential python3-dev portaudio19-dev ibus gir1.2-ibus-1.0 python3-gi
79
84
 
80
85
  # 2. Install voiceio
81
86
  pipx install python-voiceio
@@ -86,11 +91,14 @@ voiceio setup
86
91
 
87
92
  That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
88
93
 
94
+ **Installing with an AI agent?** Point it at [INSTALL.md](INSTALL.md) — a terse,
95
+ copy-pasteable runbook (`voiceio setup --defaults` / `--answers '{json}'`, no TTY needed).
96
+
89
97
  <details>
90
98
  <summary><strong>Fedora</strong></summary>
91
99
 
92
100
  ```bash
93
- sudo dnf install pipx ibus python3-gobject python3-devel portaudio-devel
101
+ sudo dnf install pipx gcc gcc-c++ make python3-devel portaudio-devel ibus ibus-libs python3-gobject
94
102
  pipx install python-voiceio
95
103
  voiceio setup
96
104
  ```
@@ -100,8 +108,8 @@ voiceio setup
100
108
  <summary><strong>Arch Linux</strong></summary>
101
109
 
102
110
  ```bash
103
- sudo pacman -S python-pipx ibus python-gobject portaudio
104
- # Note: Arch includes Python headers by default with the python package
111
+ sudo pacman -S python-pipx base-devel portaudio ibus python-gobject
112
+ # base-devel provides gcc/make; the python package ships headers.
105
113
  pipx install python-voiceio
106
114
  voiceio setup
107
115
  ```
@@ -30,8 +30,9 @@ Open-source, sovereign voice input/output. Your speech is transcribed on your ow
30
30
  ## Quick start
31
31
 
32
32
  ```bash
33
- # 1. Install system dependencies (Ubuntu/Debian)
34
- sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi python3-dev portaudio19-dev
33
+ # 1. Install system dependencies (Ubuntu/Debian). build-essential + python3-dev
34
+ # are needed to compile evdev (no prebuilt wheel); portaudio for the mic.
35
+ sudo apt install pipx build-essential python3-dev portaudio19-dev ibus gir1.2-ibus-1.0 python3-gi
35
36
 
36
37
  # 2. Install voiceio
37
38
  pipx install python-voiceio
@@ -42,11 +43,14 @@ voiceio setup
42
43
 
43
44
  That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
44
45
 
46
+ **Installing with an AI agent?** Point it at [INSTALL.md](INSTALL.md) — a terse,
47
+ copy-pasteable runbook (`voiceio setup --defaults` / `--answers '{json}'`, no TTY needed).
48
+
45
49
  <details>
46
50
  <summary><strong>Fedora</strong></summary>
47
51
 
48
52
  ```bash
49
- sudo dnf install pipx ibus python3-gobject python3-devel portaudio-devel
53
+ sudo dnf install pipx gcc gcc-c++ make python3-devel portaudio-devel ibus ibus-libs python3-gobject
50
54
  pipx install python-voiceio
51
55
  voiceio setup
52
56
  ```
@@ -56,8 +60,8 @@ voiceio setup
56
60
  <summary><strong>Arch Linux</strong></summary>
57
61
 
58
62
  ```bash
59
- sudo pacman -S python-pipx ibus python-gobject portaudio
60
- # Note: Arch includes Python headers by default with the python package
63
+ sudo pacman -S python-pipx base-devel portaudio ibus python-gobject
64
+ # base-devel provides gcc/make; the python package ships headers.
61
65
  pipx install python-voiceio
62
66
  voiceio setup
63
67
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -37,6 +37,10 @@ tray = ["pystray>=0.19", "Pillow>=10.0"]
37
37
  tts = ["piper-tts>=1.2.0"]
38
38
  tts-cloud = ["edge-tts>=6.1.0"]
39
39
  dev = ["pytest>=7.0", "pytest-mock"]
40
+ # Convenience bundle for a full Linux install (tray icon + local TTS). The core
41
+ # Linux deps (evdev, sounddevice) are already unconditional; system packages
42
+ # (IBus, PortAudio, a C toolchain) are installed separately — see README.
43
+ linux = ["pystray>=0.19", "Pillow>=10.0", "piper-tts>=1.2.0"]
40
44
 
41
45
  [project.urls]
42
46
  Homepage = "https://github.com/Hugo0/voiceio"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -40,6 +40,10 @@ Requires-Dist: edge-tts>=6.1.0; extra == "tts-cloud"
40
40
  Provides-Extra: dev
41
41
  Requires-Dist: pytest>=7.0; extra == "dev"
42
42
  Requires-Dist: pytest-mock; extra == "dev"
43
+ Provides-Extra: linux
44
+ Requires-Dist: pystray>=0.19; extra == "linux"
45
+ Requires-Dist: Pillow>=10.0; extra == "linux"
46
+ Requires-Dist: piper-tts>=1.2.0; extra == "linux"
43
47
  Dynamic: license-file
44
48
 
45
49
  # voiceio
@@ -74,8 +78,9 @@ Open-source, sovereign voice input/output. Your speech is transcribed on your ow
74
78
  ## Quick start
75
79
 
76
80
  ```bash
77
- # 1. Install system dependencies (Ubuntu/Debian)
78
- sudo apt install pipx ibus gir1.2-ibus-1.0 python3-gi python3-dev portaudio19-dev
81
+ # 1. Install system dependencies (Ubuntu/Debian). build-essential + python3-dev
82
+ # are needed to compile evdev (no prebuilt wheel); portaudio for the mic.
83
+ sudo apt install pipx build-essential python3-dev portaudio19-dev ibus gir1.2-ibus-1.0 python3-gi
79
84
 
80
85
  # 2. Install voiceio
81
86
  pipx install python-voiceio
@@ -86,11 +91,14 @@ voiceio setup
86
91
 
87
92
  That's it. Press **Ctrl+Alt+V** (or your chosen hotkey) to start dictating.
88
93
 
94
+ **Installing with an AI agent?** Point it at [INSTALL.md](INSTALL.md) — a terse,
95
+ copy-pasteable runbook (`voiceio setup --defaults` / `--answers '{json}'`, no TTY needed).
96
+
89
97
  <details>
90
98
  <summary><strong>Fedora</strong></summary>
91
99
 
92
100
  ```bash
93
- sudo dnf install pipx ibus python3-gobject python3-devel portaudio-devel
101
+ sudo dnf install pipx gcc gcc-c++ make python3-devel portaudio-devel ibus ibus-libs python3-gobject
94
102
  pipx install python-voiceio
95
103
  voiceio setup
96
104
  ```
@@ -100,8 +108,8 @@ voiceio setup
100
108
  <summary><strong>Arch Linux</strong></summary>
101
109
 
102
110
  ```bash
103
- sudo pacman -S python-pipx ibus python-gobject portaudio
104
- # Note: Arch includes Python headers by default with the python package
111
+ sudo pacman -S python-pipx base-devel portaudio ibus python-gobject
112
+ # base-devel provides gcc/make; the python package ships headers.
105
113
  pipx install python-voiceio
106
114
  voiceio setup
107
115
  ```
@@ -17,6 +17,7 @@ tests/test_backend_probes.py
17
17
  tests/test_cli.py
18
18
  tests/test_clipboard_read.py
19
19
  tests/test_commands.py
20
+ tests/test_concurrency_lockdown.py
20
21
  tests/test_config.py
21
22
  tests/test_correct_batch.py
22
23
  tests/test_corrections.py
@@ -24,6 +25,7 @@ tests/test_fallback.py
24
25
  tests/test_health.py
25
26
  tests/test_hints.py
26
27
  tests/test_history.py
28
+ tests/test_ibus_pending.py
27
29
  tests/test_ibus_typer.py
28
30
  tests/test_llm.py
29
31
  tests/test_llm_api.py
@@ -36,12 +38,14 @@ tests/test_prompt.py
36
38
  tests/test_recorder_integration.py
37
39
  tests/test_retention.py
38
40
  tests/test_robustness.py
41
+ tests/test_security_hardening.py
39
42
  tests/test_snapshots.py
40
43
  tests/test_streaming.py
41
44
  tests/test_transcriber.py
42
45
  tests/test_tts.py
43
46
  tests/test_vad.py
44
47
  tests/test_vocabulary.py
48
+ tests/test_wizard.py
45
49
  tests/test_wordfreq.py
46
50
  voiceio/__init__.py
47
51
  voiceio/__main__.py
@@ -54,6 +58,7 @@ voiceio/cli.py
54
58
  voiceio/clipboard_read.py
55
59
  voiceio/commands.py
56
60
  voiceio/config.py
61
+ voiceio/consent.py
57
62
  voiceio/corrections.py
58
63
  voiceio/demo.py
59
64
  voiceio/feedback.py
@@ -87,6 +92,7 @@ voiceio/hotkeys/pynput_backend.py
87
92
  voiceio/hotkeys/socket_backend.py
88
93
  voiceio/ibus/__init__.py
89
94
  voiceio/ibus/engine.py
95
+ voiceio/ibus/pending.py
90
96
  voiceio/models/__init__.py
91
97
  voiceio/models/silero_vad.onnx
92
98
  voiceio/sounds/__init__.py
@@ -16,6 +16,11 @@ win11toast>=0.36
16
16
  pytest>=7.0
17
17
  pytest-mock
18
18
 
19
+ [linux]
20
+ pystray>=0.19
21
+ Pillow>=10.0
22
+ piper-tts>=1.2.0
23
+
19
24
  [tray]
20
25
  pystray>=0.19
21
26
  Pillow>=10.0
@@ -27,7 +27,10 @@ def _make_vio(mock_transcriber=None):
27
27
  mock_detect.return_value = MagicMock(display_server="wayland", desktop="gnome")
28
28
 
29
29
  from voiceio.app import VoiceIO
30
- vio = VoiceIO(Config())
30
+ cfg = Config()
31
+ # Tests must never touch the real system clipboard
32
+ cfg.output.copy_to_clipboard = "off"
33
+ vio = VoiceIO(cfg)
31
34
  mock_stream = MagicMock()
32
35
  mock_stream.active = True
33
36
  mock_stream.closed = False
@@ -209,3 +212,51 @@ class TestStateMachine:
209
212
  vio._request_stop()
210
213
  assert vio._state == _State.IDLE
211
214
  assert not vio.recorder.is_recording
215
+
216
+
217
+ class TestClipboardMirror:
218
+ """copy_to_clipboard: interim/final text mirrored to the clipboard."""
219
+
220
+ def test_interim_copy_skipped_for_clipboard_typer(self):
221
+ vio, mock_typer, _ = _make_vio()
222
+ mock_typer.name = "clipboard"
223
+ vio.cfg.output.copy_to_clipboard = "live"
224
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
225
+ vio._on_interim_text("hello")
226
+ mock_copy.assert_not_called()
227
+
228
+ def test_interim_copy_fires_for_other_typers(self):
229
+ vio, mock_typer, _ = _make_vio()
230
+ mock_typer.name = "ibus"
231
+ vio.cfg.output.copy_to_clipboard = "live"
232
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
233
+ vio._on_interim_text("hello")
234
+ mock_copy.assert_called_once_with("hello")
235
+
236
+ def test_interim_copy_off_unless_live(self):
237
+ vio, mock_typer, _ = _make_vio()
238
+ mock_typer.name = "ibus"
239
+ vio.cfg.output.copy_to_clipboard = "final"
240
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
241
+ vio._on_interim_text("hello")
242
+ mock_copy.assert_not_called()
243
+
244
+ def test_final_copy_respects_off(self):
245
+ vio, mock_typer, _ = _make_vio()
246
+ mock_typer.name = "ibus"
247
+ vio.cfg.output.copy_to_clipboard = "off"
248
+ with patch("voiceio.clipboard_read.copy_text") as mock_copy:
249
+ vio._copy_result_async("hello")
250
+ time.sleep(0.1)
251
+ mock_copy.assert_not_called()
252
+
253
+ def test_final_copy_runs_async(self):
254
+ vio, mock_typer, _ = _make_vio()
255
+ mock_typer.name = "ibus"
256
+ vio.cfg.output.copy_to_clipboard = "final"
257
+ done = threading.Event()
258
+ with patch("voiceio.clipboard_read.copy_text",
259
+ side_effect=lambda t: done.set()) as mock_copy:
260
+ vio._copy_result_async("hello world")
261
+ assert done.wait(timeout=2)
262
+ mock_copy.assert_called_once_with("hello world")
@@ -89,3 +89,57 @@ def test_read_text_no_tools():
89
89
  from voiceio.clipboard_read import read_text
90
90
  result = read_text()
91
91
  assert result is None
92
+
93
+
94
+ # --- copy_text ---
95
+
96
+ def test_copy_text_wayland():
97
+ """Wayland copies via wl-copy with text on stdin."""
98
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
99
+ patch("shutil.which", return_value="/usr/bin/wl-copy"), \
100
+ patch("subprocess.run") as mock_run:
101
+ mock_run.return_value = MagicMock(returncode=0)
102
+ from voiceio.clipboard_read import copy_text
103
+ assert copy_text("hello") is True
104
+ args = mock_run.call_args[0][0]
105
+ assert "wl-copy" in args
106
+ assert mock_run.call_args.kwargs["input"] == b"hello"
107
+
108
+
109
+ def test_copy_text_x11_xclip():
110
+ """X11 copies to the CLIPBOARD selection via xclip."""
111
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="x11")), \
112
+ patch("shutil.which", lambda t: "/usr/bin/xclip" if t == "xclip" else None), \
113
+ patch("subprocess.run") as mock_run:
114
+ mock_run.return_value = MagicMock(returncode=0)
115
+ from voiceio.clipboard_read import copy_text
116
+ assert copy_text("hello") is True
117
+ args = mock_run.call_args[0][0]
118
+ assert args[:1] == ["xclip"]
119
+ assert "clipboard" in args
120
+
121
+
122
+ def test_copy_text_no_tools():
123
+ """Returns False (no raise) when no clipboard tools available."""
124
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="x11")), \
125
+ patch("shutil.which", return_value=None):
126
+ from voiceio.clipboard_read import copy_text
127
+ assert copy_text("hello") is False
128
+
129
+
130
+ def test_copy_text_empty():
131
+ """Empty text is never copied."""
132
+ with patch("subprocess.run") as mock_run:
133
+ from voiceio.clipboard_read import copy_text
134
+ assert copy_text("") is False
135
+ mock_run.assert_not_called()
136
+
137
+
138
+ def test_copy_text_command_failure():
139
+ """Nonzero exit from the copy tool reports False."""
140
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
141
+ patch("shutil.which", return_value="/usr/bin/wl-copy"), \
142
+ patch("subprocess.run") as mock_run:
143
+ mock_run.return_value = MagicMock(returncode=1)
144
+ from voiceio.clipboard_read import copy_text
145
+ assert copy_text("hello") is False
@@ -0,0 +1,254 @@
1
+ """Regression tests for the concurrency lockdown + IBus good-citizenship work.
2
+
3
+ Covers:
4
+ #1 IBus input-source is never re-forced while IDLE; mid-record switch-away
5
+ falls back to clipboard instead of fighting the user.
6
+ #2 IBus deactivation is generation-checked and restores the user's source.
7
+ #3 Watchdog typer/platform mutation goes through lock-guarded, IDLE-gated
8
+ swap helpers.
9
+ #8 SIGTERM runs the same shutdown path as Ctrl-C.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ from voiceio.config import Config
17
+ from voiceio.typers.base import TyperBackend
18
+
19
+
20
+ def _make_app(typer_name="clipboard"):
21
+ """VoiceIO with mocked backends; typer.name configurable."""
22
+ mock_hotkey = MagicMock()
23
+ mock_hotkey.name = "socket"
24
+ mock_typer = MagicMock(spec=TyperBackend)
25
+ mock_typer.name = typer_name
26
+
27
+ with patch("voiceio.app.hotkey_chain.select", return_value=mock_hotkey), \
28
+ patch("voiceio.app.typer_chain.select", return_value=mock_typer), \
29
+ patch("voiceio.app.Transcriber", return_value=MagicMock()), \
30
+ patch("voiceio.app.plat.detect") as mock_detect:
31
+ mock_detect.return_value = MagicMock(
32
+ display_server="wayland", desktop="gnome", is_gnome=True,
33
+ )
34
+ from voiceio.app import VoiceIO
35
+ vio = VoiceIO(Config())
36
+ mock_stream = MagicMock()
37
+ mock_stream.active = True
38
+ mock_stream.closed = False
39
+ mock_stream.stopped = False
40
+ vio.recorder._stream = mock_stream
41
+ vio.recorder._last_callback_time = time.monotonic()
42
+ return vio
43
+
44
+
45
+ # ── Fix #3: lock-guarded, IDLE-gated swaps ──────────────────────────────
46
+
47
+ class TestGuardedSwaps:
48
+ def test_swap_typer_only_when_idle(self):
49
+ from voiceio.app import _State
50
+ vio = _make_app()
51
+ new = MagicMock(spec=TyperBackend)
52
+ new.name = "ydotool"
53
+
54
+ vio._state = _State.RECORDING
55
+ assert vio._swap_typer(new, "test") is False
56
+ assert vio._typer is not new # not swapped mid-recording
57
+
58
+ vio._state = _State.IDLE
59
+ assert vio._swap_typer(new, "test") is True
60
+ assert vio._typer is new
61
+
62
+ def test_swap_platform_only_when_idle(self):
63
+ from voiceio.app import _State
64
+ vio = _make_app()
65
+ original = vio.platform
66
+ new_platform = MagicMock(desktop="kde")
67
+
68
+ vio._state = _State.FINALIZING
69
+ assert vio._swap_platform(new_platform) is False
70
+ assert vio.platform is original
71
+
72
+ vio._state = _State.IDLE
73
+ assert vio._swap_platform(new_platform) is True
74
+ assert vio.platform is new_platform
75
+
76
+ def test_health_upkeep_skipped_when_not_idle(self):
77
+ """_check_health must not run typer upkeep during a live recording."""
78
+ from voiceio.app import _State
79
+ vio = _make_app()
80
+ vio._state = _State.RECORDING
81
+ vio.transcriber.is_worker_alive.return_value = True
82
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
83
+ vio.cfg.tray.enabled = False
84
+ vio._health_typer_upkeep = MagicMock()
85
+ vio._check_health()
86
+ vio._health_typer_upkeep.assert_not_called()
87
+
88
+
89
+ # ── Fix #2: generation-checked deactivation + source restore ─────────────
90
+
91
+ class TestIBusDeactivation:
92
+ def test_deactivate_skipped_when_superseded(self):
93
+ vio = _make_app(typer_name="ibus")
94
+ vio._generation = 6
95
+ with patch("voiceio.app.threading.Thread") as MockThread:
96
+ vio._deactivate_ibus(gen=5) # a newer recording already started
97
+ MockThread.assert_not_called()
98
+
99
+ def test_deactivate_runs_when_current(self):
100
+ vio = _make_app(typer_name="ibus")
101
+ vio._generation = 6
102
+ with patch("voiceio.app.threading.Thread") as MockThread:
103
+ vio._deactivate_ibus(gen=6)
104
+ MockThread.assert_called_once()
105
+
106
+ def test_deactivate_noop_for_non_ibus(self):
107
+ vio = _make_app(typer_name="clipboard")
108
+ with patch("voiceio.app.threading.Thread") as MockThread:
109
+ vio._deactivate_ibus(gen=1)
110
+ MockThread.assert_not_called()
111
+
112
+ def test_restore_uses_recorded_prev_index(self):
113
+ vio = _make_app(typer_name="ibus")
114
+ vio._prev_input_source_index = 2
115
+ vio._voiceio_source_index = 4
116
+ calls = []
117
+ vio._set_gnome_input_source_index = lambda i: calls.append(i)
118
+ vio._restore_input_source()
119
+ assert calls == [2] # restores the user's source, not hardcoded 0
120
+ assert vio._prev_input_source_index is None
121
+ assert vio._voiceio_source_index is None
122
+
123
+ def test_restore_defaults_to_zero_when_unknown(self):
124
+ vio = _make_app(typer_name="ibus")
125
+ vio._prev_input_source_index = None
126
+ calls = []
127
+ vio._set_gnome_input_source_index = lambda i: calls.append(i)
128
+ vio._restore_input_source()
129
+ assert calls == [0]
130
+
131
+ def test_do_stop_does_not_deactivate_synchronously(self):
132
+ """Fix #2: deactivation is deferred to after the final commit, not run
133
+ inside _do_stop (which would race the finalizer's commit)."""
134
+ from voiceio.app import _State
135
+ vio = _make_app(typer_name="ibus")
136
+ vio._deactivate_ibus = MagicMock()
137
+ vio._state = _State.RECORDING
138
+ vio._record_start = time.monotonic() - 2
139
+ # Give the recorder something to return so a finalizer is spawned.
140
+ vio.recorder.stop = MagicMock(return_value=None)
141
+ vio._session = MagicMock()
142
+ with patch("voiceio.app.threading.Thread"):
143
+ vio._do_stop()
144
+ # For the streaming path the finalizer owns deactivation, so _do_stop
145
+ # itself must not have called it.
146
+ vio._deactivate_ibus.assert_not_called()
147
+
148
+
149
+ # ── Fix #1: no re-force while IDLE; mid-record fallback ──────────────────
150
+
151
+ class TestIBusGoodCitizen:
152
+ def _idle_healthy_ibus(self):
153
+ from voiceio.app import _State
154
+ vio = _make_app(typer_name="ibus")
155
+ vio._state = _State.IDLE
156
+ vio.transcriber.is_worker_alive.return_value = True
157
+ vio.recorder.stream_health = MagicMock(return_value=(True, ""))
158
+ vio.cfg.tray.enabled = False
159
+ vio._health_typer_upkeep = MagicMock()
160
+ vio._engine_proc = MagicMock()
161
+ vio._engine_proc.poll.return_value = None # alive
162
+ vio._ping_ibus_engine = MagicMock(return_value=True) # responsive
163
+ return vio
164
+
165
+ def test_idle_never_touches_input_source(self):
166
+ vio = self._idle_healthy_ibus()
167
+ vio._set_gnome_input_source_index = MagicMock()
168
+ vio._switch_gnome_input_source = MagicMock()
169
+ vio._detect_input_source_hijack = MagicMock()
170
+ vio._check_health()
171
+ # A responsive engine while IDLE => no source re-forcing at all.
172
+ vio._set_gnome_input_source_index.assert_not_called()
173
+ vio._switch_gnome_input_source.assert_not_called()
174
+ vio._detect_input_source_hijack.assert_not_called()
175
+
176
+ def test_recording_watches_for_hijack(self):
177
+ from voiceio.app import _State
178
+ vio = self._idle_healthy_ibus()
179
+ vio._state = _State.RECORDING
180
+ vio._detect_input_source_hijack = MagicMock()
181
+ vio._check_health()
182
+ vio._detect_input_source_hijack.assert_called_once()
183
+
184
+ def test_hijack_falls_back_to_clipboard(self):
185
+ vio = _make_app(typer_name="ibus")
186
+ vio._voiceio_source_index = 3
187
+ vio._get_current_input_source_index = lambda: 0 # user switched away
188
+ session = MagicMock()
189
+ vio._session = session
190
+ with patch("voiceio.typers.clipboard.ClipboardTyper", return_value=MagicMock()):
191
+ vio._detect_input_source_hijack()
192
+ assert vio._ibus_session_fallback is True
193
+ session.set_typer.assert_called_once()
194
+
195
+ def test_hijack_is_idempotent(self):
196
+ vio = _make_app(typer_name="ibus")
197
+ vio._voiceio_source_index = 3
198
+ vio._get_current_input_source_index = lambda: 0
199
+ session = MagicMock()
200
+ vio._session = session
201
+ with patch("voiceio.typers.clipboard.ClipboardTyper", return_value=MagicMock()):
202
+ vio._detect_input_source_hijack()
203
+ session.set_typer.reset_mock()
204
+ vio._detect_input_source_hijack() # already fell back
205
+ session.set_typer.assert_not_called()
206
+
207
+ def test_no_hijack_when_still_on_voiceio(self):
208
+ vio = _make_app(typer_name="ibus")
209
+ vio._voiceio_source_index = 3
210
+ vio._get_current_input_source_index = lambda: 3 # still on voiceio
211
+ session = MagicMock()
212
+ vio._session = session
213
+ vio._detect_input_source_hijack()
214
+ assert vio._ibus_session_fallback is False
215
+ session.set_typer.assert_not_called()
216
+
217
+
218
+ # ── Fix #8: SIGTERM shutdown wiring ─────────────────────────────────────
219
+
220
+ class TestSigterm:
221
+ def test_run_registers_sigterm(self, monkeypatch, tmp_path):
222
+ import signal as _signal
223
+ vio = _make_app(typer_name="clipboard")
224
+
225
+ import voiceio.config as vcfg
226
+ monkeypatch.setattr(vcfg, "PID_PATH", tmp_path / "pid")
227
+ monkeypatch.setattr(vcfg, "LOG_DIR", tmp_path / "logs")
228
+ monkeypatch.setattr("voiceio.pidlock.lock_pid_file", lambda fd: None)
229
+ # Neutralize anything that could touch the real session/system.
230
+ monkeypatch.setattr("voiceio.app._import_graphical_env", lambda: None)
231
+ monkeypatch.setattr("voiceio.app._redetect_platform", lambda: vio.platform)
232
+ vio._stop_ibus_engine = MagicMock()
233
+ vio._set_gnome_input_source_index = MagicMock()
234
+ vio._try_upgrade_typer = MagicMock()
235
+ vio.cfg.tray.enabled = False
236
+ vio.cfg.feedback.sound_enabled = False
237
+ vio._hotkey = MagicMock()
238
+ vio._socket = None
239
+ vio._tts_hotkey = None
240
+
241
+ handlers = {}
242
+ monkeypatch.setattr(
243
+ "voiceio.app.signal.signal",
244
+ lambda sig, handler: handlers.__setitem__(sig, handler),
245
+ )
246
+
247
+ vio._shutdown.set() # make run()'s wait return immediately
248
+ vio.run()
249
+
250
+ assert _signal.SIGTERM in handlers
251
+ # Invoking the handler must trigger the shared shutdown path.
252
+ vio._shutdown.clear()
253
+ handlers[_signal.SIGTERM]()
254
+ assert vio._shutdown.is_set()
@@ -61,3 +61,16 @@ def test_migrate_x11_to_pynput():
61
61
  raw = {"hotkey": {"backend": "x11"}}
62
62
  result = _migrate_v1(raw)
63
63
  assert result["hotkey"]["backend"] == "pynput"
64
+
65
+
66
+ def test_copy_to_clipboard_default_and_override(tmp_path):
67
+ cfg = load(path=tmp_path / "nonexistent.toml")
68
+ assert cfg.output.copy_to_clipboard == "final"
69
+
70
+ config_file = tmp_path / "config.toml"
71
+ config_file.write_text(textwrap.dedent("""\
72
+ [output]
73
+ copy_to_clipboard = "live"
74
+ """))
75
+ cfg = load(path=config_file)
76
+ assert cfg.output.copy_to_clipboard == "live"
@@ -0,0 +1,38 @@
1
+ """Tests for the IBus engine's pre-ready command buffer (fix #7).
2
+
3
+ The buffer must never replay commands that went stale before the engine
4
+ instance existed, and must be cleared outright when a fresh engine is created.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from voiceio.ibus.pending import PendingBuffer
9
+
10
+
11
+ def test_fresh_commands_are_replayed_in_order():
12
+ buf = PendingBuffer(max_age=3.0)
13
+ buf.add("preedit:a", now=100.0)
14
+ buf.add("commit:b", now=100.5)
15
+ assert buf.drain_fresh(now=101.0) == ["preedit:a", "commit:b"]
16
+ assert len(buf) == 0 # draining empties the buffer
17
+
18
+
19
+ def test_stale_commands_are_dropped():
20
+ buf = PendingBuffer(max_age=3.0)
21
+ buf.add("commit:old", now=100.0) # 5s before drain -> stale
22
+ buf.add("commit:new", now=104.0) # 1s before drain -> fresh
23
+ assert buf.drain_fresh(now=105.0) == ["commit:new"]
24
+
25
+
26
+ def test_all_stale_yields_nothing():
27
+ buf = PendingBuffer(max_age=3.0)
28
+ buf.add("commit:x", now=0.0)
29
+ assert buf.drain_fresh(now=100.0) == []
30
+
31
+
32
+ def test_clear_empties_buffer():
33
+ buf = PendingBuffer()
34
+ buf.add("preedit:z")
35
+ assert len(buf) == 1
36
+ buf.clear()
37
+ assert len(buf) == 0
38
+ assert buf.drain_fresh() == []