python-voiceio 0.2.0__py3-none-any.whl
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.2.0.dist-info/METADATA +260 -0
- python_voiceio-0.2.0.dist-info/RECORD +43 -0
- python_voiceio-0.2.0.dist-info/WHEEL +5 -0
- python_voiceio-0.2.0.dist-info/entry_points.txt +6 -0
- python_voiceio-0.2.0.dist-info/licenses/LICENSE +21 -0
- python_voiceio-0.2.0.dist-info/top_level.txt +1 -0
- voiceio/__init__.py +1 -0
- voiceio/__main__.py +3 -0
- voiceio/app.py +415 -0
- voiceio/backends.py +13 -0
- voiceio/cli.py +475 -0
- voiceio/config.py +136 -0
- voiceio/feedback.py +78 -0
- voiceio/health.py +194 -0
- voiceio/hotkeys/__init__.py +22 -0
- voiceio/hotkeys/base.py +27 -0
- voiceio/hotkeys/chain.py +83 -0
- voiceio/hotkeys/evdev.py +134 -0
- voiceio/hotkeys/pynput_backend.py +80 -0
- voiceio/hotkeys/socket_backend.py +77 -0
- voiceio/ibus/__init__.py +8 -0
- voiceio/ibus/engine.py +268 -0
- voiceio/platform.py +139 -0
- voiceio/recorder.py +208 -0
- voiceio/service.py +234 -0
- voiceio/sounds/__init__.py +0 -0
- voiceio/sounds/commit.wav +0 -0
- voiceio/sounds/start.wav +0 -0
- voiceio/sounds/stop.wav +0 -0
- voiceio/streaming.py +202 -0
- voiceio/transcriber.py +165 -0
- voiceio/tray.py +54 -0
- voiceio/typers/__init__.py +31 -0
- voiceio/typers/base.py +44 -0
- voiceio/typers/chain.py +79 -0
- voiceio/typers/clipboard.py +110 -0
- voiceio/typers/ibus.py +389 -0
- voiceio/typers/pynput_type.py +51 -0
- voiceio/typers/wtype.py +57 -0
- voiceio/typers/xdotool.py +45 -0
- voiceio/typers/ydotool.py +115 -0
- voiceio/wizard.py +882 -0
- voiceio/worker.py +39 -0
voiceio/cli.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""CLI entry point with subcommands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Main entry point: voiceio [command] [options]."""
|
|
13
|
+
from voiceio import __version__
|
|
14
|
+
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="voiceio",
|
|
17
|
+
description="Voice-to-text for Linux. Speak naturally, and text appears at your cursor.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("-V", "--version", action="version",
|
|
20
|
+
version=f"%(prog)s {__version__}")
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
|
|
23
|
+
# ── voiceio (no subcommand) = run daemon ──────────────────────────
|
|
24
|
+
# These args apply to the default (run) mode
|
|
25
|
+
parser.add_argument("-c", "--config", type=str, default=None,
|
|
26
|
+
help="Path to config file")
|
|
27
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
28
|
+
help="Enable debug logging")
|
|
29
|
+
parser.add_argument("--model", type=str, default=None,
|
|
30
|
+
help="Whisper model name (tiny, base, small, medium, large-v3)")
|
|
31
|
+
parser.add_argument("--language", type=str, default=None,
|
|
32
|
+
help="Language code (en, es, fr, ...) or 'auto'")
|
|
33
|
+
parser.add_argument("--method", type=str, default=None,
|
|
34
|
+
help="Typer backend (auto, ibus, ydotool, clipboard, ...)")
|
|
35
|
+
parser.add_argument("--no-streaming", action="store_true",
|
|
36
|
+
help="Disable streaming (type all text at end)")
|
|
37
|
+
parser.add_argument("--notify-clipboard", action="store_true", default=None,
|
|
38
|
+
help="Show desktop notification on commit")
|
|
39
|
+
parser.add_argument("--no-notify-clipboard", action="store_true", default=None,
|
|
40
|
+
help="Disable desktop notification on commit")
|
|
41
|
+
|
|
42
|
+
# ── voiceio setup ─────────────────────────────────────────────────
|
|
43
|
+
sub.add_parser("setup", help="Run interactive setup wizard")
|
|
44
|
+
|
|
45
|
+
# ── voiceio doctor ────────────────────────────────────────────────
|
|
46
|
+
p_doctor = sub.add_parser("doctor", help="Run diagnostic health check")
|
|
47
|
+
p_doctor.add_argument("--fix", action="store_true",
|
|
48
|
+
help="Attempt to auto-fix issues")
|
|
49
|
+
|
|
50
|
+
# ── voiceio toggle ────────────────────────────────────────────────
|
|
51
|
+
sub.add_parser("toggle", help="Toggle recording on a running daemon")
|
|
52
|
+
|
|
53
|
+
# ── voiceio test ──────────────────────────────────────────────────
|
|
54
|
+
sub.add_parser("test", help="Run a quick microphone + transcription test")
|
|
55
|
+
|
|
56
|
+
# ── voiceio service ────────────────────────────────────────────────
|
|
57
|
+
p_service = sub.add_parser("service", help="Manage systemd autostart service")
|
|
58
|
+
p_service.add_argument("action", nargs="?", default="status",
|
|
59
|
+
choices=["install", "uninstall", "start", "stop", "status"],
|
|
60
|
+
help="Action to perform (default: status)")
|
|
61
|
+
|
|
62
|
+
# ── voiceio uninstall ──────────────────────────────────────────────
|
|
63
|
+
sub.add_parser("uninstall", help="Remove all voiceio system integrations")
|
|
64
|
+
|
|
65
|
+
# ── voiceio logs ───────────────────────────────────────────────────
|
|
66
|
+
sub.add_parser("logs", help="Show recent log output")
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args()
|
|
69
|
+
|
|
70
|
+
if args.command == "setup":
|
|
71
|
+
_cmd_setup()
|
|
72
|
+
elif args.command == "doctor":
|
|
73
|
+
_cmd_doctor(args)
|
|
74
|
+
elif args.command == "toggle":
|
|
75
|
+
_cmd_toggle()
|
|
76
|
+
elif args.command == "test":
|
|
77
|
+
_cmd_test()
|
|
78
|
+
elif args.command == "service":
|
|
79
|
+
_cmd_service(args)
|
|
80
|
+
elif args.command == "uninstall":
|
|
81
|
+
_cmd_uninstall()
|
|
82
|
+
elif args.command == "logs":
|
|
83
|
+
_cmd_logs()
|
|
84
|
+
else:
|
|
85
|
+
_cmd_run(args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_run(args: argparse.Namespace) -> None:
|
|
89
|
+
"""Run the voiceio daemon (default command)."""
|
|
90
|
+
from voiceio import config
|
|
91
|
+
cfg = config.load(path=Path(args.config) if args.config else None)
|
|
92
|
+
if args.verbose:
|
|
93
|
+
cfg.daemon.log_level = "DEBUG"
|
|
94
|
+
if args.model:
|
|
95
|
+
cfg.model.name = args.model
|
|
96
|
+
if args.language:
|
|
97
|
+
cfg.model.language = args.language
|
|
98
|
+
if args.method:
|
|
99
|
+
cfg.output.method = args.method
|
|
100
|
+
if args.no_streaming:
|
|
101
|
+
cfg.output.streaming = False
|
|
102
|
+
if args.notify_clipboard:
|
|
103
|
+
cfg.feedback.notify_clipboard = True
|
|
104
|
+
elif args.no_notify_clipboard:
|
|
105
|
+
cfg.feedback.notify_clipboard = False
|
|
106
|
+
|
|
107
|
+
# Console: show voiceio messages at configured level
|
|
108
|
+
console = logging.StreamHandler()
|
|
109
|
+
console.setFormatter(logging.Formatter(
|
|
110
|
+
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%H:%M:%S",
|
|
111
|
+
))
|
|
112
|
+
console.setLevel(logging.WARNING)
|
|
113
|
+
|
|
114
|
+
# File: always log DEBUG to rotating file
|
|
115
|
+
from voiceio.config import LOG_DIR, LOG_PATH
|
|
116
|
+
from logging.handlers import RotatingFileHandler
|
|
117
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
file_handler = RotatingFileHandler(
|
|
119
|
+
str(LOG_PATH), maxBytes=2_000_000, backupCount=2,
|
|
120
|
+
)
|
|
121
|
+
file_handler.setFormatter(logging.Formatter(
|
|
122
|
+
"%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
123
|
+
))
|
|
124
|
+
file_handler.setLevel(logging.DEBUG)
|
|
125
|
+
|
|
126
|
+
logging.basicConfig(level=logging.DEBUG, handlers=[console, file_handler])
|
|
127
|
+
logging.getLogger("voiceio").setLevel(getattr(logging, cfg.daemon.log_level))
|
|
128
|
+
|
|
129
|
+
from voiceio.app import VoiceIO
|
|
130
|
+
app = VoiceIO(cfg)
|
|
131
|
+
signal.signal(signal.SIGTERM, lambda *_: app.request_shutdown())
|
|
132
|
+
app.run()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_setup() -> None:
|
|
136
|
+
"""Run interactive setup wizard."""
|
|
137
|
+
from voiceio.wizard import run_wizard
|
|
138
|
+
run_wizard()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cmd_doctor(args: argparse.Namespace) -> None:
|
|
142
|
+
"""Run diagnostic health check, offer to fix issues."""
|
|
143
|
+
from voiceio.health import check_health, format_report
|
|
144
|
+
report = check_health()
|
|
145
|
+
print(format_report(report))
|
|
146
|
+
|
|
147
|
+
fixable = [b for b in report.hotkey_backends + report.typer_backends
|
|
148
|
+
if not b.ok and b.fix_cmd]
|
|
149
|
+
|
|
150
|
+
if not args.fix:
|
|
151
|
+
if fixable:
|
|
152
|
+
names = ", ".join(b.name for b in fixable)
|
|
153
|
+
print(f"\nRun 'voiceio doctor --fix' to auto-fix: {names}")
|
|
154
|
+
sys.exit(0 if report.all_ok else 1)
|
|
155
|
+
|
|
156
|
+
# Auto-fix mode
|
|
157
|
+
print("\nAttempting fixes...\n")
|
|
158
|
+
import subprocess
|
|
159
|
+
|
|
160
|
+
fixed_any = False
|
|
161
|
+
for b in report.hotkey_backends + report.typer_backends:
|
|
162
|
+
if not b.ok and b.fix_cmd:
|
|
163
|
+
cmd_str = " ".join(b.fix_cmd)
|
|
164
|
+
print(f" Fixing {b.name}: {cmd_str}")
|
|
165
|
+
try:
|
|
166
|
+
subprocess.run(b.fix_cmd, check=True, timeout=10)
|
|
167
|
+
print(" Done.")
|
|
168
|
+
fixed_any = True
|
|
169
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
170
|
+
print(f" Failed: {e}")
|
|
171
|
+
|
|
172
|
+
# CLI symlinks
|
|
173
|
+
if not report.cli_in_path:
|
|
174
|
+
print(" Fixing CLI: creating symlinks in ~/.local/bin/")
|
|
175
|
+
try:
|
|
176
|
+
from voiceio.service import install_symlinks
|
|
177
|
+
linked = install_symlinks()
|
|
178
|
+
if linked:
|
|
179
|
+
print(f" Done, linked: {', '.join(linked)}")
|
|
180
|
+
fixed_any = True
|
|
181
|
+
else:
|
|
182
|
+
print(" Failed: no scripts found to link")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
print(f" Failed: {e}")
|
|
185
|
+
|
|
186
|
+
# IBus-specific fixes
|
|
187
|
+
ibus_broken = [b for b in report.typer_backends
|
|
188
|
+
if b.name == "ibus" and not b.ok]
|
|
189
|
+
for b in ibus_broken:
|
|
190
|
+
if "input sources" in b.reason.lower():
|
|
191
|
+
print(f" Fixing {b.name}: adding VoiceIO to GNOME input sources")
|
|
192
|
+
try:
|
|
193
|
+
from voiceio.typers.ibus import _ensure_gnome_input_source
|
|
194
|
+
_ensure_gnome_input_source()
|
|
195
|
+
print(" Done.")
|
|
196
|
+
fixed_any = True
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f" Failed: {e}")
|
|
199
|
+
elif "component" in b.reason.lower():
|
|
200
|
+
print(f" Fixing {b.name}: installing IBus component")
|
|
201
|
+
try:
|
|
202
|
+
from voiceio.typers.ibus import install_component
|
|
203
|
+
install_component()
|
|
204
|
+
print(" Done.")
|
|
205
|
+
fixed_any = True
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print(f" Failed: {e}")
|
|
208
|
+
|
|
209
|
+
if fixed_any:
|
|
210
|
+
print("\nRe-checking...")
|
|
211
|
+
report = check_health()
|
|
212
|
+
print(format_report(report))
|
|
213
|
+
|
|
214
|
+
sys.exit(0 if report.all_ok else 1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _cmd_toggle() -> None:
|
|
218
|
+
"""Send toggle command to running daemon."""
|
|
219
|
+
from voiceio.hotkeys.socket_backend import send_toggle
|
|
220
|
+
if not send_toggle():
|
|
221
|
+
print("voiceio daemon is not running. Start it with: voiceio", file=sys.stderr)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _cmd_test() -> None:
|
|
226
|
+
"""Run a quick microphone + transcription test."""
|
|
227
|
+
from voiceio.wizard import run_test
|
|
228
|
+
run_test()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _cmd_service(args: argparse.Namespace) -> None:
|
|
232
|
+
"""Manage the systemd user service."""
|
|
233
|
+
from voiceio.service import (
|
|
234
|
+
install_service, uninstall_service, is_installed, is_running,
|
|
235
|
+
start_service, SERVICE_PATH,
|
|
236
|
+
)
|
|
237
|
+
import subprocess
|
|
238
|
+
|
|
239
|
+
action = args.action
|
|
240
|
+
|
|
241
|
+
if action == "status":
|
|
242
|
+
installed = is_installed()
|
|
243
|
+
running = is_running()
|
|
244
|
+
print(f"Service installed: {'yes' if installed else 'no'}")
|
|
245
|
+
if installed:
|
|
246
|
+
print(f"Service running: {'yes' if running else 'no'}")
|
|
247
|
+
print(f"Service file: {SERVICE_PATH}")
|
|
248
|
+
else:
|
|
249
|
+
print("Run 'voiceio service install' to set up autostart.")
|
|
250
|
+
sys.exit(0 if installed else 1)
|
|
251
|
+
|
|
252
|
+
elif action == "install":
|
|
253
|
+
if install_service():
|
|
254
|
+
print("Service installed and enabled. It will start on next login.")
|
|
255
|
+
print("Start now: systemctl --user start voiceio")
|
|
256
|
+
else:
|
|
257
|
+
print("Failed to install service.", file=sys.stderr)
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
|
|
260
|
+
elif action == "uninstall":
|
|
261
|
+
uninstall_service()
|
|
262
|
+
print("Service disabled and removed.")
|
|
263
|
+
|
|
264
|
+
elif action == "start":
|
|
265
|
+
if not is_installed():
|
|
266
|
+
print("Service not installed. Run 'voiceio service install' first.", file=sys.stderr)
|
|
267
|
+
sys.exit(1)
|
|
268
|
+
if start_service():
|
|
269
|
+
print("Service started.")
|
|
270
|
+
else:
|
|
271
|
+
print("Failed to start service.", file=sys.stderr)
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
|
|
274
|
+
elif action == "stop":
|
|
275
|
+
try:
|
|
276
|
+
subprocess.run(
|
|
277
|
+
["systemctl", "--user", "stop", "voiceio.service"],
|
|
278
|
+
capture_output=True, timeout=5,
|
|
279
|
+
)
|
|
280
|
+
print("Service stopped.")
|
|
281
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
282
|
+
print("Failed to stop service.", file=sys.stderr)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cmd_uninstall() -> None:
|
|
287
|
+
"""Remove all voiceio system integrations."""
|
|
288
|
+
import os
|
|
289
|
+
import shutil
|
|
290
|
+
import subprocess
|
|
291
|
+
|
|
292
|
+
home = Path.home()
|
|
293
|
+
removed: list[str] = []
|
|
294
|
+
|
|
295
|
+
answer = input("This will remove all voiceio system files. Continue? [y/N] ").strip().lower()
|
|
296
|
+
if answer != "y":
|
|
297
|
+
print("Aborted.")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# 1. Stop running daemons and disable systemd service
|
|
301
|
+
# Kill any running voiceio daemon (manual or systemd)
|
|
302
|
+
try:
|
|
303
|
+
result = subprocess.run(
|
|
304
|
+
["pgrep", "-f", "voiceio.cli"],
|
|
305
|
+
capture_output=True, text=True, timeout=3,
|
|
306
|
+
)
|
|
307
|
+
if result.returncode == 0:
|
|
308
|
+
my_pid = str(os.getpid())
|
|
309
|
+
for pid in result.stdout.strip().split("\n"):
|
|
310
|
+
pid = pid.strip()
|
|
311
|
+
if pid and pid != my_pid:
|
|
312
|
+
subprocess.run(["kill", pid], capture_output=True, timeout=3)
|
|
313
|
+
removed.append("Running voiceio daemon(s)")
|
|
314
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
# Kill any running IBus engine process
|
|
318
|
+
try:
|
|
319
|
+
result = subprocess.run(
|
|
320
|
+
["pgrep", "-f", "voiceio.ibus.engine"],
|
|
321
|
+
capture_output=True, text=True, timeout=3,
|
|
322
|
+
)
|
|
323
|
+
if result.returncode == 0:
|
|
324
|
+
for pid in result.stdout.strip().split("\n"):
|
|
325
|
+
pid = pid.strip()
|
|
326
|
+
if pid:
|
|
327
|
+
subprocess.run(["kill", pid], capture_output=True, timeout=3)
|
|
328
|
+
removed.append("Running IBus engine(s)")
|
|
329
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
# Stop and disable systemd service
|
|
333
|
+
service_path = home / ".config" / "systemd" / "user" / "voiceio.service"
|
|
334
|
+
if service_path.exists():
|
|
335
|
+
try:
|
|
336
|
+
subprocess.run(
|
|
337
|
+
["systemctl", "--user", "stop", "voiceio.service"],
|
|
338
|
+
capture_output=True, timeout=5,
|
|
339
|
+
)
|
|
340
|
+
subprocess.run(
|
|
341
|
+
["systemctl", "--user", "disable", "voiceio.service"],
|
|
342
|
+
capture_output=True, timeout=5,
|
|
343
|
+
)
|
|
344
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
345
|
+
pass
|
|
346
|
+
service_path.unlink(missing_ok=True)
|
|
347
|
+
try:
|
|
348
|
+
subprocess.run(
|
|
349
|
+
["systemctl", "--user", "daemon-reload"],
|
|
350
|
+
capture_output=True, timeout=5,
|
|
351
|
+
)
|
|
352
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
353
|
+
pass
|
|
354
|
+
removed.append(str(service_path))
|
|
355
|
+
|
|
356
|
+
# 2. Remove IBus component and launcher
|
|
357
|
+
ibus_component = home / ".local" / "share" / "ibus" / "component" / "voiceio.xml"
|
|
358
|
+
ibus_launcher = home / ".local" / "share" / "voiceio" / "voiceio-ibus-engine"
|
|
359
|
+
if ibus_component.exists():
|
|
360
|
+
ibus_component.unlink()
|
|
361
|
+
removed.append(str(ibus_component))
|
|
362
|
+
if ibus_launcher.exists():
|
|
363
|
+
ibus_launcher.unlink()
|
|
364
|
+
removed.append(str(ibus_launcher))
|
|
365
|
+
# Remove parent dir if empty
|
|
366
|
+
launcher_dir = ibus_launcher.parent
|
|
367
|
+
try:
|
|
368
|
+
launcher_dir.rmdir()
|
|
369
|
+
removed.append(str(launcher_dir))
|
|
370
|
+
except OSError:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# 3. Remove GNOME input source entry
|
|
374
|
+
try:
|
|
375
|
+
result = subprocess.run(
|
|
376
|
+
["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
|
|
377
|
+
capture_output=True, text=True, timeout=3,
|
|
378
|
+
)
|
|
379
|
+
if result.returncode == 0 and "'ibus', 'voiceio'" in result.stdout:
|
|
380
|
+
import ast
|
|
381
|
+
sources = ast.literal_eval(result.stdout.strip())
|
|
382
|
+
new_sources = [s for s in sources if s != ("ibus", "voiceio")]
|
|
383
|
+
formatted = "[" + ", ".join(f"({s[0]!r}, {s[1]!r})" for s in new_sources) + "]"
|
|
384
|
+
subprocess.run(
|
|
385
|
+
["gsettings", "set", "org.gnome.desktop.input-sources", "sources", formatted],
|
|
386
|
+
capture_output=True, timeout=3,
|
|
387
|
+
)
|
|
388
|
+
removed.append("GNOME input source ('ibus', 'voiceio')")
|
|
389
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError, SyntaxError):
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
# 4. Remove environment.d file
|
|
393
|
+
env_file = home / ".config" / "environment.d" / "voiceio.conf"
|
|
394
|
+
if env_file.exists():
|
|
395
|
+
env_file.unlink()
|
|
396
|
+
removed.append(str(env_file))
|
|
397
|
+
|
|
398
|
+
# 5. Remove CLI symlinks from ~/.local/bin/
|
|
399
|
+
local_bin = home / ".local" / "bin"
|
|
400
|
+
symlink_names = ["voiceio", "voiceio-toggle", "voiceio-doctor", "voiceio-setup", "voiceio-test"]
|
|
401
|
+
for name in symlink_names:
|
|
402
|
+
link = local_bin / name
|
|
403
|
+
if link.is_symlink():
|
|
404
|
+
link.unlink()
|
|
405
|
+
removed.append(str(link))
|
|
406
|
+
|
|
407
|
+
# 6. Optionally remove config
|
|
408
|
+
config_dir = home / ".config" / "voiceio"
|
|
409
|
+
if config_dir.exists():
|
|
410
|
+
answer = input("Remove config too? [y/N] ").strip().lower()
|
|
411
|
+
if answer == "y":
|
|
412
|
+
shutil.rmtree(config_dir)
|
|
413
|
+
removed.append(str(config_dir))
|
|
414
|
+
|
|
415
|
+
# 7. Optionally remove logs
|
|
416
|
+
log_dir = home / ".local" / "state" / "voiceio"
|
|
417
|
+
if log_dir.exists():
|
|
418
|
+
answer = input("Remove logs too? [y/N] ").strip().lower()
|
|
419
|
+
if answer == "y":
|
|
420
|
+
shutil.rmtree(log_dir)
|
|
421
|
+
removed.append(str(log_dir))
|
|
422
|
+
|
|
423
|
+
# Print summary
|
|
424
|
+
if removed:
|
|
425
|
+
print("\nRemoved:")
|
|
426
|
+
for item in removed:
|
|
427
|
+
print(f" - {item}")
|
|
428
|
+
else:
|
|
429
|
+
print("\nNothing to remove. voiceio was not installed on this system.")
|
|
430
|
+
|
|
431
|
+
# Offer to uninstall the Python package itself
|
|
432
|
+
is_pipx = "pipx" in sys.prefix
|
|
433
|
+
if is_pipx:
|
|
434
|
+
answer = input("\nAlso uninstall the voiceio Python package (pipx uninstall)? [Y/n] ").strip().lower()
|
|
435
|
+
if answer in ("y", "yes", ""):
|
|
436
|
+
try:
|
|
437
|
+
subprocess.run(["pipx", "uninstall", "voiceio"], timeout=30)
|
|
438
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
439
|
+
print("Failed. Run manually: pipx uninstall voiceio")
|
|
440
|
+
else:
|
|
441
|
+
# Dev install or pip install: check if voiceio is still reachable
|
|
442
|
+
voiceio_bin = shutil.which("voiceio")
|
|
443
|
+
if voiceio_bin:
|
|
444
|
+
print(f"\nNote: 'voiceio' is still available at {voiceio_bin}")
|
|
445
|
+
if ".venv" in str(voiceio_bin) or "site-packages" in str(voiceio_bin):
|
|
446
|
+
print("This is a development install. To fully remove:")
|
|
447
|
+
print(" pip uninstall voiceio")
|
|
448
|
+
else:
|
|
449
|
+
print("To fully remove the package:")
|
|
450
|
+
print(" pip uninstall voiceio")
|
|
451
|
+
else:
|
|
452
|
+
print("\nvoiceio fully removed.")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _cmd_logs() -> None:
|
|
456
|
+
"""Show recent log output (last 50 lines)."""
|
|
457
|
+
from voiceio.config import LOG_PATH
|
|
458
|
+
if not LOG_PATH.exists():
|
|
459
|
+
print("No log file found. Start voiceio first.", file=sys.stderr)
|
|
460
|
+
sys.exit(1)
|
|
461
|
+
try:
|
|
462
|
+
with open(LOG_PATH) as f:
|
|
463
|
+
lines = f.readlines()
|
|
464
|
+
for line in lines[-50:]:
|
|
465
|
+
print(line, end="")
|
|
466
|
+
except OSError as e:
|
|
467
|
+
print(f"Cannot read log file: {e}", file=sys.stderr)
|
|
468
|
+
sys.exit(1)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# Legacy entry points for voiceio-doctor (parses its own --fix flag)
|
|
472
|
+
def _cmd_doctor_legacy() -> None:
|
|
473
|
+
parser = argparse.ArgumentParser(prog="voiceio-doctor")
|
|
474
|
+
parser.add_argument("--fix", action="store_true", help="Attempt to auto-fix issues")
|
|
475
|
+
_cmd_doctor(parser.parse_args())
|
voiceio/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Configuration schema, loading, and v1 migration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".config" / "voiceio"
|
|
13
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
14
|
+
LOG_DIR = Path.home() / ".local" / "state" / "voiceio"
|
|
15
|
+
LOG_PATH = LOG_DIR / "voiceio.log"
|
|
16
|
+
PID_PATH = LOG_DIR / "voiceio.pid"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class HotkeyConfig:
|
|
21
|
+
key: str = "ctrl+alt+v"
|
|
22
|
+
backend: str = "auto"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ModelConfig:
|
|
27
|
+
name: str = "base"
|
|
28
|
+
language: str = "en"
|
|
29
|
+
device: str = "auto"
|
|
30
|
+
compute_type: str = "int8"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AudioConfig:
|
|
35
|
+
sample_rate: int = 16000
|
|
36
|
+
device: str = "default"
|
|
37
|
+
prebuffer_secs: float = 1.0
|
|
38
|
+
silence_threshold: float = 0.01
|
|
39
|
+
silence_duration: float = 0.6
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class OutputConfig:
|
|
44
|
+
method: str = "auto"
|
|
45
|
+
streaming: bool = True
|
|
46
|
+
min_recording_secs: float = 1.5
|
|
47
|
+
cancel_window_secs: float = 0.5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class FeedbackConfig:
|
|
52
|
+
sound_enabled: bool = True
|
|
53
|
+
notify_clipboard: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TrayConfig:
|
|
58
|
+
enabled: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class DaemonConfig:
|
|
63
|
+
log_level: str = "INFO"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class HealthConfig:
|
|
68
|
+
auto_fallback: bool = True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Config:
|
|
73
|
+
hotkey: HotkeyConfig = field(default_factory=HotkeyConfig)
|
|
74
|
+
model: ModelConfig = field(default_factory=ModelConfig)
|
|
75
|
+
audio: AudioConfig = field(default_factory=AudioConfig)
|
|
76
|
+
output: OutputConfig = field(default_factory=OutputConfig)
|
|
77
|
+
feedback: FeedbackConfig = field(default_factory=FeedbackConfig)
|
|
78
|
+
tray: TrayConfig = field(default_factory=TrayConfig)
|
|
79
|
+
daemon: DaemonConfig = field(default_factory=DaemonConfig)
|
|
80
|
+
health: HealthConfig = field(default_factory=HealthConfig)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _migrate_v1(raw: dict) -> dict:
|
|
84
|
+
"""Migrate v1 config values to v2."""
|
|
85
|
+
# Remove deprecated cpu_threads
|
|
86
|
+
if "model" in raw and "cpu_threads" in raw["model"]:
|
|
87
|
+
del raw["model"]["cpu_threads"]
|
|
88
|
+
log.info("Config migration: removed deprecated model.cpu_threads")
|
|
89
|
+
|
|
90
|
+
# Migrate old output methods
|
|
91
|
+
if "output" in raw and "method" in raw["output"]:
|
|
92
|
+
method = raw["output"]["method"]
|
|
93
|
+
if method in ("xclip", "wl-copy"):
|
|
94
|
+
raw["output"]["method"] = "clipboard"
|
|
95
|
+
log.info("Config migration: output.method '%s' → 'clipboard'", method)
|
|
96
|
+
|
|
97
|
+
# Migrate old hotkey backend names
|
|
98
|
+
if "hotkey" in raw and "backend" in raw["hotkey"]:
|
|
99
|
+
if raw["hotkey"]["backend"] == "x11":
|
|
100
|
+
raw["hotkey"]["backend"] = "pynput"
|
|
101
|
+
log.info("Config migration: hotkey.backend 'x11' → 'pynput'")
|
|
102
|
+
|
|
103
|
+
return raw
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build(cls, section: dict):
|
|
107
|
+
"""Build a dataclass from a dict, ignoring unknown keys."""
|
|
108
|
+
valid = {f.name for f in dataclasses.fields(cls)}
|
|
109
|
+
filtered = {k: v for k, v in section.items() if k in valid}
|
|
110
|
+
unknown = set(section) - valid
|
|
111
|
+
if unknown:
|
|
112
|
+
log.warning("Ignoring unknown config keys in [%s]: %s", cls.__name__, ", ".join(unknown))
|
|
113
|
+
return cls(**filtered)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load(path: Path | None = None) -> Config:
|
|
117
|
+
path = path or CONFIG_PATH
|
|
118
|
+
raw: dict = {}
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "rb") as f:
|
|
122
|
+
raw = tomllib.load(f)
|
|
123
|
+
raw = _migrate_v1(raw)
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
return Config(
|
|
128
|
+
hotkey=_build(HotkeyConfig, raw.get("hotkey", {})),
|
|
129
|
+
model=_build(ModelConfig, raw.get("model", {})),
|
|
130
|
+
audio=_build(AudioConfig, raw.get("audio", {})),
|
|
131
|
+
output=_build(OutputConfig, raw.get("output", {})),
|
|
132
|
+
feedback=_build(FeedbackConfig, raw.get("feedback", {})),
|
|
133
|
+
tray=_build(TrayConfig, raw.get("tray", {})),
|
|
134
|
+
daemon=_build(DaemonConfig, raw.get("daemon", {})),
|
|
135
|
+
health=_build(HealthConfig, raw.get("health", {})),
|
|
136
|
+
)
|
voiceio/feedback.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""User feedback: notifications and audio cues."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_SOUNDS_DIR = Path(__file__).parent / "sounds"
|
|
14
|
+
|
|
15
|
+
# Cache tool lookups; they don't change during a session
|
|
16
|
+
_which = functools.lru_cache(maxsize=16)(shutil.which)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def play_record_start() -> None:
|
|
20
|
+
"""Play a subtle sound when recording starts. Runs async."""
|
|
21
|
+
threading.Thread(target=_play_wav, args=(_SOUNDS_DIR / "start.wav",), daemon=True).start()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def play_record_stop() -> None:
|
|
25
|
+
"""Play a subtle sound when recording stops. Runs async."""
|
|
26
|
+
threading.Thread(target=_play_wav, args=(_SOUNDS_DIR / "stop.wav",), daemon=True).start()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def play_commit_sound() -> None:
|
|
30
|
+
"""Play a short success sound when text is committed. Runs async."""
|
|
31
|
+
threading.Thread(target=_play_wav, args=(_SOUNDS_DIR / "commit.wav",), daemon=True).start()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def notify_clipboard(text: str) -> None:
|
|
35
|
+
"""Show desktop notification that text is in clipboard.
|
|
36
|
+
|
|
37
|
+
Runs async so it never blocks the typing pipeline.
|
|
38
|
+
"""
|
|
39
|
+
preview = text[:80] + ("\u2026" if len(text) > 80 else "")
|
|
40
|
+
threading.Thread(
|
|
41
|
+
target=_send_notification,
|
|
42
|
+
args=("VoiceIO: copied to clipboard", preview),
|
|
43
|
+
daemon=True,
|
|
44
|
+
).start()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _send_notification(title: str, body: str) -> None:
|
|
48
|
+
"""Send a desktop notification via notify-send (GNOME/freedesktop)."""
|
|
49
|
+
if not _which("notify-send"):
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(
|
|
53
|
+
["notify-send", "--app-name=VoiceIO", "-t", "3000", title, body],
|
|
54
|
+
capture_output=True, timeout=3,
|
|
55
|
+
)
|
|
56
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _play_wav(path: Path) -> None:
|
|
61
|
+
"""Play a WAV file via paplay, aplay, or pw-play."""
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return
|
|
64
|
+
for player, args in [
|
|
65
|
+
("paplay", [str(path)]),
|
|
66
|
+
("pw-play", [str(path)]),
|
|
67
|
+
("aplay", ["-q", str(path)]),
|
|
68
|
+
]:
|
|
69
|
+
if not _which(player):
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
subprocess.run(
|
|
73
|
+
[player, *args],
|
|
74
|
+
capture_output=True, timeout=3,
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
78
|
+
continue
|