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.
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