speaksy 0.1.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.
speaksy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Speaksy - Voice typing for Linux."""
2
+
3
+ __version__ = "0.1.0"
speaksy/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m speaksy."""
2
+
3
+ from speaksy.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
speaksy/cli.py ADDED
@@ -0,0 +1,307 @@
1
+ """Interactive CLI for Speaksy."""
2
+
3
+ import sys
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.prompt import Prompt, Confirm
8
+ from rich.table import Table
9
+
10
+ from speaksy import __version__
11
+ from speaksy import config
12
+ from speaksy import service
13
+ from speaksy.setup_wizard import run_setup
14
+
15
+ console = Console()
16
+
17
+
18
+ def print_banner():
19
+ """Print the speaksy banner."""
20
+ banner = """
21
+ [bold cyan]╭────────────────────────────────────────╮[/bold cyan]
22
+ [bold cyan]│[/bold cyan] [bold white]SPEAKSY[/bold white] [bold cyan]│[/bold cyan]
23
+ [bold cyan]│[/bold cyan] [dim]talk it. type it. ship it.[/dim] [bold cyan]│[/bold cyan]
24
+ [bold cyan]╰────────────────────────────────────────╯[/bold cyan]
25
+ """
26
+ console.print(banner)
27
+
28
+
29
+ def get_status_display() -> str:
30
+ """Get the current status as a styled string."""
31
+ if not config.is_configured():
32
+ return "[cyan]waiting for setup[/cyan]"
33
+ elif service.is_running():
34
+ return "[green]vibing[/green]"
35
+ else:
36
+ return "[yellow]sleeping[/yellow]"
37
+
38
+
39
+ def print_status_line():
40
+ """Print the current status line."""
41
+ status = get_status_display()
42
+ console.print(f" [bold]Status:[/bold] {status}")
43
+
44
+ if config.is_configured():
45
+ ptt, toggle = config.get_hotkeys()
46
+ ptt_display = ptt.replace("Key.", "").replace("_", " ").title()
47
+ toggle_display = toggle.replace("Key.", "").upper()
48
+ console.print(f" [bold]Hotkeys:[/bold] {ptt_display} (hold) | {toggle_display} (toggle)")
49
+ console.print()
50
+
51
+
52
+ def print_commands():
53
+ """Print available commands."""
54
+ console.print(" [dim]Commands:[/dim]")
55
+ console.print(" [cyan]/setup[/cyan] get your keys in here")
56
+ console.print(" [cyan]/start[/cyan] let's gooo")
57
+ console.print(" [cyan]/stop[/cyan] take a break")
58
+ console.print(" [cyan]/status[/cyan] what's the vibe?")
59
+ console.print(" [cyan]/logs[/cyan] receipts")
60
+ console.print(" [cyan]/config[/cyan] tweak the drip")
61
+ console.print(" [cyan]/help[/cyan] need backup?")
62
+ console.print(" [cyan]/quit[/cyan] peace out")
63
+ console.print()
64
+
65
+
66
+ def cmd_setup():
67
+ """Run the setup wizard."""
68
+ run_setup()
69
+
70
+
71
+ def cmd_start():
72
+ """Start the service."""
73
+ if not config.is_configured():
74
+ console.print("[yellow]hold up, run /setup first[/yellow]")
75
+ return
76
+
77
+ if service.is_running():
78
+ console.print("[yellow]already vibing fam[/yellow]")
79
+ return
80
+
81
+ console.print("[dim]starting...[/dim]")
82
+ if service.start_service():
83
+ console.print("[green]lesss gooo! speaksy is now listening...[/green]")
84
+ ptt, toggle = config.get_hotkeys()
85
+ ptt_display = ptt.replace("Key.", "").replace("_", " ").title()
86
+ toggle_display = toggle.replace("Key.", "").upper()
87
+ console.print(f" hold [cyan]{ptt_display}[/cyan] or tap [cyan]{toggle_display}[/cyan] to speak")
88
+ else:
89
+ console.print("[red]couldn't start the service[/red]")
90
+ console.print("[dim]check /logs for more info[/dim]")
91
+
92
+
93
+ def cmd_stop():
94
+ """Stop the service."""
95
+ if not service.is_running():
96
+ console.print("[yellow]already sleeping[/yellow]")
97
+ return
98
+
99
+ console.print("[dim]stopping...[/dim]")
100
+ if service.stop_service():
101
+ console.print("[yellow]aight speaksy is taking a nap. run /start when you need me[/yellow]")
102
+ else:
103
+ console.print("[red]couldn't stop the service[/red]")
104
+
105
+
106
+ def cmd_status():
107
+ """Show detailed status."""
108
+ console.print()
109
+ console.print("[bold]the vibe check:[/bold]")
110
+
111
+ status = service.get_status()
112
+
113
+ if status["running"]:
114
+ uptime = status.get("uptime", "unknown")
115
+ console.print(f" [green]├─ service: running for {uptime}[/green]")
116
+ elif status["installed"]:
117
+ console.print(" [yellow]├─ service: stopped[/yellow]")
118
+ else:
119
+ console.print(" [red]├─ service: not installed[/red]")
120
+
121
+ if config.api_key_exists():
122
+ console.print(" [green]├─ api key: configured ✓[/green]")
123
+ else:
124
+ console.print(" [red]├─ api key: not set[/red]")
125
+
126
+ mode = config.get_privacy_mode()
127
+ if mode == "local":
128
+ console.print(" [cyan]└─ mode: local (privacy mode)[/cyan]")
129
+ else:
130
+ console.print(" [green]└─ mode: cloud (groq)[/green]")
131
+
132
+ console.print()
133
+
134
+
135
+ def cmd_logs():
136
+ """Show recent logs."""
137
+ console.print()
138
+ console.print("[bold]receipts:[/bold]")
139
+ console.print()
140
+ logs = service.get_logs(lines=15)
141
+ console.print(f"[dim]{logs}[/dim]")
142
+ console.print()
143
+
144
+
145
+ def cmd_config():
146
+ """Interactive config editor."""
147
+ console.print()
148
+ console.print("[bold]settings:[/bold]")
149
+ console.print()
150
+ console.print(" 1. API key")
151
+ console.print(" 2. Hotkeys")
152
+ console.print(" 3. Privacy mode (cloud/local)")
153
+ console.print(" 4. Text cleanup on/off")
154
+ console.print(" 5. Back")
155
+ console.print()
156
+
157
+ choice = Prompt.ask("[cyan]pick one[/cyan]", choices=["1", "2", "3", "4", "5"], default="5")
158
+
159
+ if choice == "1":
160
+ console.print()
161
+ new_key = Prompt.ask("[cyan]new API key[/cyan]", password=True)
162
+ if new_key:
163
+ config.save_api_key(new_key)
164
+ console.print("[green]saved ✓[/green]")
165
+ if service.is_running():
166
+ service.restart_service()
167
+ console.print("[dim]service restarted[/dim]")
168
+
169
+ elif choice == "2":
170
+ console.print()
171
+ console.print("[dim]examples: Key.ctrl_r, Key.f8, Key.alt_l[/dim]")
172
+ ptt, toggle = config.get_hotkeys()
173
+
174
+ new_ptt = Prompt.ask(f"[cyan]push-to-talk[/cyan]", default=ptt)
175
+ new_toggle = Prompt.ask(f"[cyan]toggle[/cyan]", default=toggle)
176
+
177
+ config.set_hotkeys(new_ptt, new_toggle)
178
+ console.print("[green]saved ✓[/green]")
179
+
180
+ if service.is_running():
181
+ service.restart_service()
182
+ console.print("[dim]service restarted[/dim]")
183
+
184
+ elif choice == "3":
185
+ console.print()
186
+ current = config.get_privacy_mode()
187
+ console.print(f"[dim]currently: {current}[/dim]")
188
+ console.print()
189
+ console.print(" 1. [green]cloud[/green] - Groq API, <1s response")
190
+ console.print(" 2. [cyan]local[/cyan] - runs on your CPU, ~3-5s response")
191
+ console.print()
192
+
193
+ mode_choice = Prompt.ask("[cyan]pick[/cyan]", choices=["1", "2"], default="1" if current == "cloud" else "2")
194
+ new_mode = "cloud" if mode_choice == "1" else "local"
195
+ config.set_privacy_mode(new_mode)
196
+
197
+ if new_mode == "local":
198
+ console.print("[cyan]switched to local-only mode[/cyan]")
199
+ console.print("[dim]your voice never leaves your machine[/dim]")
200
+ else:
201
+ console.print("[green]switched to cloud mode[/green]")
202
+
203
+ if service.is_running():
204
+ service.restart_service()
205
+
206
+ elif choice == "4":
207
+ console.print()
208
+ current = config.get_cleanup_enabled()
209
+ console.print(f"[dim]currently: {'on' if current else 'off'}[/dim]")
210
+
211
+ new_state = Confirm.ask("[cyan]enable text cleanup?[/cyan]", default=current)
212
+ config.set_cleanup_enabled(new_state)
213
+ console.print(f"[green]cleanup {'enabled' if new_state else 'disabled'} ✓[/green]")
214
+
215
+ if service.is_running():
216
+ service.restart_service()
217
+
218
+ console.print()
219
+
220
+
221
+ def cmd_help():
222
+ """Show help."""
223
+ console.print()
224
+ console.print("[bold]speaksy commands:[/bold]")
225
+ console.print()
226
+
227
+ table = Table(show_header=False, box=None, padding=(0, 2))
228
+ table.add_column("Command", style="cyan")
229
+ table.add_column("Description")
230
+
231
+ table.add_row("/setup", "Run the setup wizard (API key, hotkeys, etc.)")
232
+ table.add_row("/start", "Start the voice typing service")
233
+ table.add_row("/stop", "Stop the voice typing service")
234
+ table.add_row("/status", "Show detailed status info")
235
+ table.add_row("/logs", "View recent service logs")
236
+ table.add_row("/config", "Edit settings (hotkeys, privacy mode, etc.)")
237
+ table.add_row("/help", "Show this help message")
238
+ table.add_row("/quit", "Exit CLI (service keeps running)")
239
+
240
+ console.print(table)
241
+ console.print()
242
+ console.print("[dim]tip: service runs in background, so you can close this anytime[/dim]")
243
+ console.print()
244
+
245
+
246
+ def run_repl():
247
+ """Run the interactive REPL."""
248
+ commands = {
249
+ "/setup": cmd_setup,
250
+ "/start": cmd_start,
251
+ "/stop": cmd_stop,
252
+ "/status": cmd_status,
253
+ "/logs": cmd_logs,
254
+ "/config": cmd_config,
255
+ "/help": cmd_help,
256
+ "/quit": lambda: None,
257
+ "/exit": lambda: None,
258
+ "/q": lambda: None,
259
+ }
260
+
261
+ while True:
262
+ try:
263
+ user_input = Prompt.ask("[bold magenta]speaksy>[/bold magenta]").strip().lower()
264
+
265
+ if not user_input:
266
+ continue
267
+
268
+ if user_input in ("/quit", "/exit", "/q"):
269
+ console.print("[dim]peace out ✌️[/dim]")
270
+ break
271
+
272
+ if user_input in commands:
273
+ commands[user_input]()
274
+ elif user_input.startswith("/"):
275
+ console.print(f"[red]unknown command: {user_input}[/red]")
276
+ console.print("[dim]type /help for available commands[/dim]")
277
+ else:
278
+ console.print("[dim]commands start with / (try /help)[/dim]")
279
+
280
+ except KeyboardInterrupt:
281
+ console.print()
282
+ console.print("[dim]peace out ✌️[/dim]")
283
+ break
284
+ except EOFError:
285
+ break
286
+
287
+
288
+ def main():
289
+ """Main entry point."""
290
+ print_banner()
291
+ print_status_line()
292
+
293
+ # Auto-run setup if not configured
294
+ if not config.is_configured():
295
+ console.print("[yellow]looks like this is your first time![/yellow]")
296
+ console.print("[dim]let's get you set up...[/dim]")
297
+ console.print()
298
+ run_setup()
299
+ console.print()
300
+ else:
301
+ print_commands()
302
+
303
+ run_repl()
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
speaksy/config.py ADDED
@@ -0,0 +1,157 @@
1
+ """Configuration management for Speaksy."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ # XDG config directory
9
+ CONFIG_DIR = Path.home() / ".config" / "speaksy"
10
+ CONFIG_FILE = CONFIG_DIR / "config.yaml"
11
+ ENV_FILE = CONFIG_DIR / ".env"
12
+
13
+ DEFAULT_CONFIG = {
14
+ "stt": {
15
+ "primary": "groq",
16
+ "groq_model": "whisper-large-v3-turbo",
17
+ "local_model": "base",
18
+ "local_device": "cpu",
19
+ "local_compute_type": "int8",
20
+ "language": "en",
21
+ },
22
+ "cleanup": {
23
+ "enabled": True,
24
+ "model": "llama-3.1-8b-instant",
25
+ },
26
+ "audio": {
27
+ "sample_rate": 16000,
28
+ "channels": 1,
29
+ "pre_buffer_seconds": 0.5,
30
+ },
31
+ "hotkeys": {
32
+ "push_to_talk": "Key.ctrl_r",
33
+ "toggle": "Key.f8",
34
+ },
35
+ "text_injection": {
36
+ "method": "clipboard",
37
+ "restore_clipboard": True,
38
+ },
39
+ "tray": {
40
+ "enabled": True,
41
+ },
42
+ }
43
+
44
+
45
+ def deep_merge(base: dict, override: dict) -> dict:
46
+ """Recursively merge override into base dict."""
47
+ result = base.copy()
48
+ for key, value in override.items():
49
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
50
+ result[key] = deep_merge(result[key], value)
51
+ else:
52
+ result[key] = value
53
+ return result
54
+
55
+
56
+ def ensure_config_dir():
57
+ """Create config directory if it doesn't exist."""
58
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
59
+
60
+
61
+ def config_exists() -> bool:
62
+ """Check if config file exists."""
63
+ return CONFIG_FILE.exists()
64
+
65
+
66
+ def load_config() -> dict:
67
+ """Load config from YAML, merge with defaults."""
68
+ config = DEFAULT_CONFIG.copy()
69
+ if CONFIG_FILE.exists():
70
+ with open(CONFIG_FILE) as f:
71
+ user_config = yaml.safe_load(f) or {}
72
+ config = deep_merge(DEFAULT_CONFIG, user_config)
73
+ return config
74
+
75
+
76
+ def save_config(config: dict):
77
+ """Save config to YAML file."""
78
+ ensure_config_dir()
79
+ with open(CONFIG_FILE, "w") as f:
80
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
81
+
82
+
83
+ def get_api_key() -> str:
84
+ """Get API key from .env file."""
85
+ if ENV_FILE.exists():
86
+ with open(ENV_FILE) as f:
87
+ for line in f:
88
+ line = line.strip()
89
+ if line.startswith("GROQ_API_KEY="):
90
+ return line.split("=", 1)[1].strip()
91
+ return os.getenv("GROQ_API_KEY", "")
92
+
93
+
94
+ def save_api_key(api_key: str):
95
+ """Save API key to .env file."""
96
+ ensure_config_dir()
97
+ with open(ENV_FILE, "w") as f:
98
+ f.write(f"GROQ_API_KEY={api_key}\n")
99
+ # Set restrictive permissions
100
+ os.chmod(ENV_FILE, 0o600)
101
+
102
+
103
+ def api_key_exists() -> bool:
104
+ """Check if API key is configured."""
105
+ return bool(get_api_key())
106
+
107
+
108
+ def is_configured() -> bool:
109
+ """Check if speaksy is fully configured."""
110
+ return config_exists() and api_key_exists()
111
+
112
+
113
+ def get_hotkeys() -> tuple:
114
+ """Get current hotkey settings."""
115
+ config = load_config()
116
+ hotkeys = config.get("hotkeys", {})
117
+ return (
118
+ hotkeys.get("push_to_talk", "Key.ctrl_r"),
119
+ hotkeys.get("toggle", "Key.f8"),
120
+ )
121
+
122
+
123
+ def set_hotkeys(push_to_talk: str, toggle: str):
124
+ """Update hotkey settings."""
125
+ config = load_config()
126
+ config["hotkeys"]["push_to_talk"] = push_to_talk
127
+ config["hotkeys"]["toggle"] = toggle
128
+ save_config(config)
129
+
130
+
131
+ def get_privacy_mode() -> str:
132
+ """Get current privacy mode (cloud or local)."""
133
+ config = load_config()
134
+ primary = config.get("stt", {}).get("primary", "groq")
135
+ return "local" if primary == "local" else "cloud"
136
+
137
+
138
+ def set_privacy_mode(mode: str):
139
+ """Set privacy mode (cloud or local)."""
140
+ config = load_config()
141
+ config["stt"]["primary"] = "local" if mode == "local" else "groq"
142
+ save_config(config)
143
+
144
+
145
+ def get_cleanup_enabled() -> bool:
146
+ """Check if text cleanup is enabled."""
147
+ config = load_config()
148
+ return config.get("cleanup", {}).get("enabled", True)
149
+
150
+
151
+ def set_cleanup_enabled(enabled: bool):
152
+ """Enable or disable text cleanup."""
153
+ config = load_config()
154
+ if "cleanup" not in config:
155
+ config["cleanup"] = {}
156
+ config["cleanup"]["enabled"] = enabled
157
+ save_config(config)