softvoice 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.
- softvoice/__init__.py +3 -0
- softvoice/__main__.py +3 -0
- softvoice/autostart.py +92 -0
- softvoice/clean.py +152 -0
- softvoice/cli.py +391 -0
- softvoice/client.py +97 -0
- softvoice/config.py +130 -0
- softvoice/daemon.py +392 -0
- softvoice/front.py +123 -0
- softvoice/highlight.py +457 -0
- softvoice/hooks.py +148 -0
- softvoice/hotkey.py +247 -0
- softvoice/install.py +115 -0
- softvoice/instance.py +97 -0
- softvoice/menubar.py +241 -0
- softvoice/panel.py +642 -0
- softvoice/permissions.py +84 -0
- softvoice/player.py +39 -0
- softvoice/ptt.py +943 -0
- softvoice/selection.py +115 -0
- softvoice/stt.py +91 -0
- softvoice/tts.py +50 -0
- softvoice/vad.py +100 -0
- softvoice-0.1.0.dist-info/METADATA +308 -0
- softvoice-0.1.0.dist-info/RECORD +28 -0
- softvoice-0.1.0.dist-info/WHEEL +4 -0
- softvoice-0.1.0.dist-info/entry_points.txt +3 -0
- softvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
softvoice/__init__.py
ADDED
softvoice/__main__.py
ADDED
softvoice/autostart.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`softvoice autostart`: run the app as a macOS login item.
|
|
2
|
+
|
|
3
|
+
A LaunchAgent in ~/Library/LaunchAgents starts `softvoice app` when you log in, with no terminal
|
|
4
|
+
involved, and starts it again if it ever crashes (not after you quit it on purpose). Permissions
|
|
5
|
+
are then attributed to the Python running softvoice rather than to a terminal, so macOS asks for
|
|
6
|
+
them once more the first time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import plistlib
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from softvoice.config import HOME_DIR, ensure_home
|
|
18
|
+
from softvoice.install import executable
|
|
19
|
+
|
|
20
|
+
LABEL = "com.softvoice.app"
|
|
21
|
+
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
22
|
+
APP_LOG = HOME_DIR / "app.log"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def program() -> list[str]:
|
|
26
|
+
exe = executable()
|
|
27
|
+
if exe:
|
|
28
|
+
return [exe]
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
return [sys.executable, "-m", "softvoice"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def plist(extra_args: list[str] | tuple[str, ...] = ()) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"Label": LABEL,
|
|
37
|
+
"ProgramArguments": [*program(), "app", *extra_args],
|
|
38
|
+
"RunAtLoad": True,
|
|
39
|
+
"KeepAlive": {"SuccessfulExit": False}, # back after a crash; not after Quit
|
|
40
|
+
"ProcessType": "Interactive",
|
|
41
|
+
"LimitLoadToSessionType": "Aqua",
|
|
42
|
+
"StandardOutPath": str(APP_LOG),
|
|
43
|
+
"StandardErrorPath": str(APP_LOG),
|
|
44
|
+
"EnvironmentVariables": {
|
|
45
|
+
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
46
|
+
"PYTHONUNBUFFERED": "1",
|
|
47
|
+
"SOFTVOICE_LAUNCHD": "1", # lets the app know a crash-exit brings it back
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _domain() -> str:
|
|
53
|
+
return f"gui/{os.getuid()}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _launchctl(*args: str) -> subprocess.CompletedProcess:
|
|
57
|
+
return subprocess.run(["launchctl", *args], capture_output=True, text=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def enable(extra_args: list[str] | tuple[str, ...] = ()) -> Path:
|
|
61
|
+
"""Write the LaunchAgent and load it, which also starts the app now."""
|
|
62
|
+
ensure_home()
|
|
63
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
with PLIST_PATH.open("wb") as fh:
|
|
65
|
+
plistlib.dump(plist(extra_args), fh)
|
|
66
|
+
_launchctl("bootout", f"{_domain()}/{LABEL}") # a previous version, if any; fine when there is none
|
|
67
|
+
result = _launchctl("bootstrap", _domain(), str(PLIST_PATH))
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
raise RuntimeError(f"launchctl bootstrap failed: {result.stderr.strip() or result.stdout.strip()}")
|
|
70
|
+
return PLIST_PATH
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def disable() -> bool:
|
|
74
|
+
"""Unload (which stops the app) and remove the LaunchAgent. False when there was none."""
|
|
75
|
+
existed = PLIST_PATH.exists()
|
|
76
|
+
_launchctl("bootout", f"{_domain()}/{LABEL}")
|
|
77
|
+
if existed:
|
|
78
|
+
PLIST_PATH.unlink()
|
|
79
|
+
return existed
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def status() -> dict:
|
|
83
|
+
"""{'installed': bool, 'loaded': bool, 'pid': int | None, 'state': str | None}"""
|
|
84
|
+
info = {"installed": PLIST_PATH.exists(), "loaded": False, "pid": None, "state": None}
|
|
85
|
+
result = _launchctl("print", f"{_domain()}/{LABEL}")
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
info["loaded"] = True
|
|
88
|
+
pid = re.search(r"^\s*pid = (\d+)", result.stdout, re.M)
|
|
89
|
+
state = re.search(r"^\s*state = (\w+)", result.stdout, re.M)
|
|
90
|
+
info["pid"] = int(pid.group(1)) if pid else None
|
|
91
|
+
info["state"] = state.group(1) if state else None
|
|
92
|
+
return info
|
softvoice/clean.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Turn an agent's markdown reply into text that sounds natural when read aloud.
|
|
2
|
+
|
|
3
|
+
Code blocks, tables, URLs and long paths are dropped or shortened: you look at the
|
|
4
|
+
screen for those. Anything that looks like a secret is replaced before it reaches a
|
|
5
|
+
speaker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
_FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.DOTALL)
|
|
14
|
+
_HTML = re.compile(r"<[^>\n]{1,200}>")
|
|
15
|
+
_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
|
16
|
+
_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
|
|
17
|
+
_URL = re.compile(r"https?://[^\s)>\]]+")
|
|
18
|
+
_INLINE_CODE = re.compile(r"`([^`\n]*)`")
|
|
19
|
+
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s*(.*?)\s*#*\s*$", re.MULTILINE)
|
|
20
|
+
_BULLET = re.compile(r"^\s*(?:[-*+•]|\d{1,3}[.)])\s+", re.MULTILINE)
|
|
21
|
+
_QUOTE = re.compile(r"^\s*>\s?", re.MULTILINE)
|
|
22
|
+
_RULE = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$", re.MULTILINE)
|
|
23
|
+
_TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
|
|
24
|
+
_EMPHASIS = re.compile(r"(\*\*|__|\*|_|~~)(?=\S)(.+?)(?<=\S)\1")
|
|
25
|
+
_CHECKBOX = re.compile(r"\[( |x|X)\]\s*")
|
|
26
|
+
# Something with at least two path separators, or a dotted filename with a line number.
|
|
27
|
+
_PATH = re.compile(r"(?<![\w./-])(?:~|\.{1,2})?[\w.@-]*(?:/[\w.@-]+){2,}(?::\d+(?::\d+)?)?")
|
|
28
|
+
_FILE_LINE = re.compile(r"\b([\w-]+\.[a-z]{1,5}):(\d+)(?::\d+)?\b")
|
|
29
|
+
_MULTISPACE = re.compile(r"[ \t]+")
|
|
30
|
+
_MULTINEWLINE = re.compile(r"\n{2,}")
|
|
31
|
+
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
32
|
+
|
|
33
|
+
_SECRETS = [
|
|
34
|
+
re.compile(p)
|
|
35
|
+
for p in (
|
|
36
|
+
r"\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b", # OpenAI / Anthropic style keys
|
|
37
|
+
r"\bgh[pousr]_[A-Za-z0-9]{20,}\b", # GitHub tokens
|
|
38
|
+
r"\bAKIA[0-9A-Z]{16}\b", # AWS access keys
|
|
39
|
+
r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", # Slack tokens
|
|
40
|
+
r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", # JWTs
|
|
41
|
+
r"\b[A-Fa-f0-9]{32,}\b", # long hex: hashes, API keys
|
|
42
|
+
r"(?<![\w/+])[A-Za-z0-9+/]{40,}={0,2}(?![\w/+])", # long base64 blobs
|
|
43
|
+
r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+",
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_SYMBOLS = [
|
|
48
|
+
(re.compile(r"\s*->\s*"), " to "),
|
|
49
|
+
(re.compile(r"\s*=>\s*"), " gives "),
|
|
50
|
+
(re.compile(r"\s*&&\s*"), " and "),
|
|
51
|
+
(re.compile(r"\s*\|\|\s*"), " or "),
|
|
52
|
+
(re.compile(r"(?<=\w)/(?=\w)"), " or "), # "input/output" -> "input or output"
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Cleaned:
|
|
58
|
+
text: str
|
|
59
|
+
truncated: bool
|
|
60
|
+
dropped_code_blocks: int
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clean(markdown: str, max_chars: int = 700, truncation_suffix: str = "More on screen.") -> Cleaned:
|
|
64
|
+
"""Return speakable text for a markdown reply. Empty text means: nothing worth saying."""
|
|
65
|
+
text = markdown.replace("\r\n", "\n").replace("\r", "\n")
|
|
66
|
+
|
|
67
|
+
dropped = len(_FENCE.findall(text))
|
|
68
|
+
text = _FENCE.sub("\n", text)
|
|
69
|
+
# An unterminated fence: drop from the fence to the end.
|
|
70
|
+
if "```" in text:
|
|
71
|
+
text = text.split("```", 1)[0]
|
|
72
|
+
dropped += 1
|
|
73
|
+
|
|
74
|
+
text = _TABLE_ROW.sub("", text)
|
|
75
|
+
text = _HTML.sub("", text)
|
|
76
|
+
text = _IMAGE.sub(lambda m: m.group(1), text)
|
|
77
|
+
text = _LINK.sub(lambda m: m.group(1), text)
|
|
78
|
+
text = _URL.sub("link", text)
|
|
79
|
+
text = _INLINE_CODE.sub(_inline_code, text)
|
|
80
|
+
text = _HEADING.sub(lambda m: _sentence(m.group(1)), text)
|
|
81
|
+
text = _RULE.sub("", text)
|
|
82
|
+
text = _QUOTE.sub("", text)
|
|
83
|
+
text = _CHECKBOX.sub("", text)
|
|
84
|
+
text = _BULLET.sub("", text)
|
|
85
|
+
for _ in range(2): # nested emphasis like ***x*** or **_x_**
|
|
86
|
+
text = _EMPHASIS.sub(lambda m: m.group(2), text)
|
|
87
|
+
text = _FILE_LINE.sub(lambda m: f"{m.group(1)} line {m.group(2)}", text)
|
|
88
|
+
text = _PATH.sub(_path_basename, text)
|
|
89
|
+
|
|
90
|
+
for pattern in _SECRETS:
|
|
91
|
+
text = pattern.sub("redacted", text)
|
|
92
|
+
for pattern, replacement in _SYMBOLS:
|
|
93
|
+
text = pattern.sub(replacement, text)
|
|
94
|
+
|
|
95
|
+
# Every line becomes a sentence so the voice pauses between bullets.
|
|
96
|
+
lines = [_sentence(line.strip()) for line in text.split("\n")]
|
|
97
|
+
text = " ".join(line for line in lines if line)
|
|
98
|
+
text = _MULTISPACE.sub(" ", text).strip()
|
|
99
|
+
text = re.sub(r"\s+([.,;:!?])", r"\1", text)
|
|
100
|
+
text = re.sub(r"([.!?])\1+", r"\1", text)
|
|
101
|
+
|
|
102
|
+
truncated = False
|
|
103
|
+
if max_chars and len(text) > max_chars:
|
|
104
|
+
text = _cut_at_sentence(text, max_chars)
|
|
105
|
+
truncated = True
|
|
106
|
+
if truncation_suffix:
|
|
107
|
+
text = f"{text} {truncation_suffix}"
|
|
108
|
+
|
|
109
|
+
# Nothing but code or symbols: say nothing.
|
|
110
|
+
if not re.search(r"[A-Za-z]{2,}", text):
|
|
111
|
+
text = ""
|
|
112
|
+
return Cleaned(text=text, truncated=truncated, dropped_code_blocks=dropped)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _inline_code(match: re.Match) -> str:
|
|
116
|
+
code = match.group(1).strip()
|
|
117
|
+
if not code or len(code) > 40:
|
|
118
|
+
return ""
|
|
119
|
+
return code
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _path_basename(match: re.Match) -> str:
|
|
123
|
+
path = match.group(0)
|
|
124
|
+
line = ""
|
|
125
|
+
if ":" in path:
|
|
126
|
+
path, _, rest = path.partition(":")
|
|
127
|
+
line = f" line {rest.split(':')[0]}"
|
|
128
|
+
name = path.rstrip("/").rsplit("/", 1)[-1]
|
|
129
|
+
return f"{name}{line}" if name else ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sentence(line: str) -> str:
|
|
133
|
+
line = line.strip()
|
|
134
|
+
if not line:
|
|
135
|
+
return ""
|
|
136
|
+
if line[-1] not in ".!?:;,":
|
|
137
|
+
line += "."
|
|
138
|
+
elif line[-1] in ":;,":
|
|
139
|
+
line = line[:-1] + "."
|
|
140
|
+
return line
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cut_at_sentence(text: str, max_chars: int) -> str:
|
|
144
|
+
head = text[:max_chars]
|
|
145
|
+
parts = _SENTENCE_END.split(head)
|
|
146
|
+
if len(parts) > 1:
|
|
147
|
+
kept = " ".join(parts[:-1]).strip()
|
|
148
|
+
if len(kept) >= max_chars // 3:
|
|
149
|
+
return kept
|
|
150
|
+
# No sentence boundary in range: cut at a word.
|
|
151
|
+
cut = head.rsplit(" ", 1)[0].rstrip(",;:")
|
|
152
|
+
return cut + "."
|
softvoice/cli.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Command-line entry point. Heavy imports stay inside the commands that need them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from softvoice import __version__
|
|
13
|
+
from softvoice.config import CONFIG_PATH, LOG_PATH, Config, write_default_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="softvoice", description="On-device voice in and out for Claude Code and Codex.")
|
|
18
|
+
parser.add_argument("--version", action="version", version=f"softvoice {__version__}")
|
|
19
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
|
|
21
|
+
p = sub.add_parser("serve", help="run the speaker daemon (normally started for you)")
|
|
22
|
+
p.add_argument("--foreground", "-f", action="store_true", help="log to the terminal as well")
|
|
23
|
+
|
|
24
|
+
p = sub.add_parser("say", help="speak text through the daemon")
|
|
25
|
+
p.add_argument("text", nargs="*", help="text to speak; reads stdin when omitted")
|
|
26
|
+
p.add_argument("--raw", action="store_true", help="skip markdown cleaning")
|
|
27
|
+
p.add_argument("--out", metavar="WAV", help="write a WAV file instead of speaking (loads the model in-process)")
|
|
28
|
+
|
|
29
|
+
sub.add_parser("stop", help="stop speaking and clear the queue")
|
|
30
|
+
sub.add_parser("mute", help="stay silent until unmuted")
|
|
31
|
+
sub.add_parser("unmute", help="speak again")
|
|
32
|
+
sub.add_parser("status", help="show daemon state")
|
|
33
|
+
sub.add_parser("restart", help="stop the daemon; the next hook starts a fresh one")
|
|
34
|
+
sub.add_parser("quit", help="stop the daemon")
|
|
35
|
+
p = sub.add_parser("voice", help="switch the voice for this daemon run")
|
|
36
|
+
p.add_argument("name", help="a predefined voice name or a WAV path to clone")
|
|
37
|
+
sub.add_parser("voices", help="list predefined voices")
|
|
38
|
+
p = sub.add_parser("log", help="show the daemon log")
|
|
39
|
+
p.add_argument("-n", type=int, default=40)
|
|
40
|
+
|
|
41
|
+
sub.add_parser("hook", help="agent hook entry: reads the hook JSON on stdin")
|
|
42
|
+
p = sub.add_parser("notify", help="Codex legacy notify entry: JSON as an argument")
|
|
43
|
+
p.add_argument("payload")
|
|
44
|
+
|
|
45
|
+
sub.add_parser("setup", help="download the models and ask macOS for the permissions, so the first run is instant")
|
|
46
|
+
p = sub.add_parser("install", help="write hooks into Claude Code and Codex settings")
|
|
47
|
+
p.add_argument("--claude-only", action="store_true")
|
|
48
|
+
p.add_argument("--codex-only", action="store_true")
|
|
49
|
+
sub.add_parser("uninstall", help="remove softvoice hooks")
|
|
50
|
+
sub.add_parser("config", help="print the config file path and contents")
|
|
51
|
+
|
|
52
|
+
p = sub.add_parser("listen", help="push-to-talk: hold the hotkey, speak, release to paste")
|
|
53
|
+
p.add_argument("--hotkey", help="the talk key: alt_r, fn, f5, or a combination such as alt+space")
|
|
54
|
+
p.add_argument("--auto-submit", action="store_true", help="press Enter after pasting")
|
|
55
|
+
p.add_argument("--no-paste", action="store_true", help="print transcripts instead of pasting")
|
|
56
|
+
p.add_argument("--hands-free", action="store_true", help="after each spoken reply, open the mic and send what you say")
|
|
57
|
+
p = sub.add_parser("app", help="menu bar app: a mic icon that shows whose turn it is, with Talk, Stop, Mute, Hands-free")
|
|
58
|
+
p.add_argument("--hotkey", help="the talk key: alt_r, fn, f5, or a combination such as alt+space")
|
|
59
|
+
p.add_argument("--auto-submit", action="store_true", help="press Enter after push-to-talk pastes")
|
|
60
|
+
p.add_argument("--no-paste", action="store_true", help="print transcripts instead of pasting")
|
|
61
|
+
hf = p.add_mutually_exclusive_group()
|
|
62
|
+
hf.add_argument("--hands-free", dest="hands_free", action="store_true", default=None)
|
|
63
|
+
hf.add_argument("--no-hands-free", dest="hands_free", action="store_false")
|
|
64
|
+
p = sub.add_parser("autostart", help="run the app as a login item: starts at login, no terminal, back after a crash")
|
|
65
|
+
p.add_argument("action", nargs="?", choices=["on", "off", "status"], default="status")
|
|
66
|
+
p.add_argument("--hands-free", action="store_true", help="start hands-free (or set hands_free = true in the config)")
|
|
67
|
+
p = sub.add_parser("transcribe", help="transcribe an audio file with Parakeet")
|
|
68
|
+
p.add_argument("file")
|
|
69
|
+
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
return COMMANDS[args.cmd](args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -- speaking -----------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def cmd_serve(args) -> int:
|
|
77
|
+
from softvoice.daemon import serve
|
|
78
|
+
|
|
79
|
+
return serve(foreground=args.foreground)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_say(args) -> int:
|
|
83
|
+
text = " ".join(args.text) if args.text else sys.stdin.read()
|
|
84
|
+
if not text.strip():
|
|
85
|
+
print("nothing to say", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
if args.out:
|
|
88
|
+
import soundfile as sf
|
|
89
|
+
|
|
90
|
+
from softvoice.clean import clean
|
|
91
|
+
from softvoice.tts import Speaker
|
|
92
|
+
|
|
93
|
+
cfg = Config.load()
|
|
94
|
+
spoken = text if args.raw else clean(text, cfg.max_chars, cfg.truncation_suffix).text
|
|
95
|
+
speaker = Speaker(cfg)
|
|
96
|
+
t0 = time.monotonic()
|
|
97
|
+
audio = speaker.synthesize(spoken)
|
|
98
|
+
sf.write(args.out, audio, speaker.sample_rate)
|
|
99
|
+
print(f"wrote {args.out}: {len(audio) / speaker.sample_rate:.1f}s audio in {time.monotonic() - t0:.1f}s")
|
|
100
|
+
return 0
|
|
101
|
+
return _send({"cmd": "speak", "text": text, "raw": args.raw, "meta": {"event": "say"}})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_simple(cmd: str, **fields):
|
|
105
|
+
def run(args) -> int:
|
|
106
|
+
return _send({"cmd": cmd, **fields}, autostart=cmd not in ("stop", "quit", "mute", "unmute"))
|
|
107
|
+
|
|
108
|
+
return run
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_status(args) -> int:
|
|
112
|
+
from softvoice.client import alive, send
|
|
113
|
+
|
|
114
|
+
if not alive():
|
|
115
|
+
print("daemon: not running")
|
|
116
|
+
return 1
|
|
117
|
+
print(json.dumps(send({"cmd": "status"}, autostart=False), indent=2))
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_restart(args) -> int:
|
|
122
|
+
from softvoice.client import alive, send
|
|
123
|
+
|
|
124
|
+
from softvoice.config import PID_PATH
|
|
125
|
+
|
|
126
|
+
old_pid = None
|
|
127
|
+
try:
|
|
128
|
+
old_pid = int(PID_PATH.read_text().strip() or 0) or None
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
if alive():
|
|
132
|
+
send({"cmd": "quit"}, autostart=False)
|
|
133
|
+
for _ in range(100): # wait for the old process itself, not just its socket
|
|
134
|
+
if not alive() and not (old_pid and _pid_running(old_pid)):
|
|
135
|
+
break
|
|
136
|
+
time.sleep(0.1)
|
|
137
|
+
from softvoice.client import ensure_daemon
|
|
138
|
+
|
|
139
|
+
ok = ensure_daemon()
|
|
140
|
+
print("daemon restarted" if ok else "daemon did not come up; see: softvoice log")
|
|
141
|
+
return 0 if ok else 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _pid_running(pid: int) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
os.kill(pid, 0)
|
|
147
|
+
return True
|
|
148
|
+
except ProcessLookupError:
|
|
149
|
+
return False
|
|
150
|
+
except PermissionError:
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_voice(args) -> int:
|
|
155
|
+
return _send({"cmd": "voice", "voice": args.name})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_voices(args) -> int:
|
|
159
|
+
from softvoice.tts import PREDEFINED_VOICES
|
|
160
|
+
|
|
161
|
+
print("\n".join(PREDEFINED_VOICES))
|
|
162
|
+
print("\nOr pass a path to a short WAV of a voice to clone. Set `voice` in", CONFIG_PATH)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_log(args) -> int:
|
|
167
|
+
if not LOG_PATH.exists():
|
|
168
|
+
print("no log yet")
|
|
169
|
+
return 0
|
|
170
|
+
lines = LOG_PATH.read_text(errors="replace").splitlines()
|
|
171
|
+
print("\n".join(lines[-args.n:]))
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _send(req: dict, autostart: bool = True) -> int:
|
|
176
|
+
from softvoice.client import DaemonUnavailable, send
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
resp = send(req, autostart=autostart)
|
|
180
|
+
except DaemonUnavailable as exc:
|
|
181
|
+
print(exc, file=sys.stderr)
|
|
182
|
+
return 1
|
|
183
|
+
if not resp.get("ok"):
|
|
184
|
+
print(resp.get("error", "failed"), file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
extras = {k: v for k, v in resp.items() if k != "ok"}
|
|
187
|
+
if extras:
|
|
188
|
+
print(json.dumps(extras))
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# -- hooks --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _hook_payload(raw: str) -> int:
|
|
195
|
+
"""Shared by `hook` (stdin) and `notify` (argv). Never fails loudly: agents must not notice us."""
|
|
196
|
+
if os.environ.get("SOFTVOICE_DISABLED"):
|
|
197
|
+
return 0
|
|
198
|
+
try:
|
|
199
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
200
|
+
cfg = Config.load()
|
|
201
|
+
from softvoice.client import send
|
|
202
|
+
from softvoice.hooks import interpret
|
|
203
|
+
|
|
204
|
+
action = interpret(payload, cfg.speak_notifications, cfg.interrupt_on_prompt,
|
|
205
|
+
whole_turn=cfg.speak_whole_turn, interim_min_chars=cfg.interim_min_chars)
|
|
206
|
+
if action.kind == "speak":
|
|
207
|
+
send({"cmd": "speak", "text": action.text, "meta": action.meta})
|
|
208
|
+
elif action.kind == "stop":
|
|
209
|
+
send({"cmd": "stop", "reason": "prompt"}, autostart=False)
|
|
210
|
+
except Exception as exc: # log and stay silent: stdout must stay clean for Codex
|
|
211
|
+
try:
|
|
212
|
+
from softvoice.config import HOME_DIR, ensure_home
|
|
213
|
+
|
|
214
|
+
ensure_home()
|
|
215
|
+
with open(HOME_DIR / "hook.log", "a") as fh:
|
|
216
|
+
fh.write(f"{time.strftime('%F %T')} {type(exc).__name__}: {exc}\n")
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_hook(args) -> int:
|
|
223
|
+
return _hook_payload(sys.stdin.read())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_notify(args) -> int:
|
|
227
|
+
return _hook_payload(args.payload)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_setup(args) -> int:
|
|
231
|
+
"""Everything that would otherwise happen, slowly, the first time you use it."""
|
|
232
|
+
from huggingface_hub import hf_hub_download
|
|
233
|
+
|
|
234
|
+
from softvoice import permissions
|
|
235
|
+
|
|
236
|
+
cfg = Config.load()
|
|
237
|
+
print(f"config: {write_default_config()}")
|
|
238
|
+
|
|
239
|
+
t0 = time.monotonic()
|
|
240
|
+
print(f"Pocket TTS ({cfg.language}, voice {cfg.voice}): about 230 MB the first time", flush=True)
|
|
241
|
+
from softvoice.tts import Speaker
|
|
242
|
+
|
|
243
|
+
Speaker(cfg)
|
|
244
|
+
print(f" ready in {time.monotonic() - t0:.0f}s")
|
|
245
|
+
|
|
246
|
+
t0 = time.monotonic()
|
|
247
|
+
print(f"Parakeet ({cfg.stt_model}): 2.5 GB the first time", flush=True)
|
|
248
|
+
for name in ("config.json", "model.safetensors"): # the same two files parakeet-mlx loads
|
|
249
|
+
hf_hub_download(cfg.stt_model, name)
|
|
250
|
+
print(f" ready in {time.monotonic() - t0:.0f}s")
|
|
251
|
+
|
|
252
|
+
print("Permissions (granted to the app this terminal belongs to):")
|
|
253
|
+
try:
|
|
254
|
+
import sounddevice as sd
|
|
255
|
+
|
|
256
|
+
with sd.InputStream(channels=1, samplerate=16000): # makes macOS ask for the microphone now
|
|
257
|
+
time.sleep(0.3)
|
|
258
|
+
print("Microphone: opened")
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
print(f"Microphone: could not open ({exc})")
|
|
261
|
+
permissions.check_and_request(need_paste=True, need_hotkey=True)
|
|
262
|
+
print("Next: `softvoice install` adds the hooks, then `softvoice app --hands-free` starts the pill.")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_install(args) -> int:
|
|
267
|
+
from softvoice import install as inst
|
|
268
|
+
|
|
269
|
+
write_default_config()
|
|
270
|
+
command = inst.hook_command()
|
|
271
|
+
print(f"hook command: {command}")
|
|
272
|
+
if not args.codex_only:
|
|
273
|
+
path, backup = inst.install_claude(command)
|
|
274
|
+
print(f"Claude Code: hooks written to {path}" + (f" (backup {backup.name})" if backup else ""))
|
|
275
|
+
print(" Restart Claude Code sessions to pick them up.")
|
|
276
|
+
if not args.claude_only:
|
|
277
|
+
result = inst.install_codex(command)
|
|
278
|
+
if result:
|
|
279
|
+
path, backup = result
|
|
280
|
+
print(f"Codex: hooks written to {path}" + (f" (backup {backup.name})" if backup else ""))
|
|
281
|
+
print(" Run /hooks inside Codex once to trust them.")
|
|
282
|
+
else:
|
|
283
|
+
print("Codex: ~/.codex not found, skipped. When you install Codex, run `softvoice install --codex-only`.")
|
|
284
|
+
print(f"config: {CONFIG_PATH}")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_uninstall(args) -> int:
|
|
289
|
+
from softvoice import install as inst
|
|
290
|
+
|
|
291
|
+
for label, fn in (("Claude Code", inst.uninstall_claude), ("Codex", inst.uninstall_codex)):
|
|
292
|
+
path = fn()
|
|
293
|
+
print(f"{label}: {'removed from ' + str(path) if path else 'nothing to remove'}")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_config(args) -> int:
|
|
298
|
+
path = write_default_config()
|
|
299
|
+
print(path)
|
|
300
|
+
print(path.read_text())
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# -- listening ----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def cmd_listen(args) -> int:
|
|
307
|
+
from softvoice.ptt import listen
|
|
308
|
+
|
|
309
|
+
cfg = Config.load()
|
|
310
|
+
return listen(cfg, hotkey=args.hotkey or cfg.hotkey, auto_submit=args.auto_submit or cfg.auto_submit,
|
|
311
|
+
paste=not args.no_paste, hands_free=args.hands_free or cfg.hands_free)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_app(args) -> int:
|
|
315
|
+
from softvoice.menubar import run_app
|
|
316
|
+
|
|
317
|
+
cfg = Config.load()
|
|
318
|
+
hands_free = cfg.hands_free if args.hands_free is None else args.hands_free
|
|
319
|
+
return run_app(cfg, hotkey=args.hotkey or cfg.hotkey, auto_submit=args.auto_submit or cfg.auto_submit,
|
|
320
|
+
paste=not args.no_paste, hands_free=hands_free)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_autostart(args) -> int:
|
|
324
|
+
from softvoice import autostart, instance
|
|
325
|
+
|
|
326
|
+
if args.action == "on":
|
|
327
|
+
other = instance.running_pid()
|
|
328
|
+
if other:
|
|
329
|
+
print(f"stopping the running copy (pid {other}) so the login item starts it fresh ...", flush=True)
|
|
330
|
+
if not instance.quit(other):
|
|
331
|
+
print("it did not stop; quit it yourself and run this again")
|
|
332
|
+
return 1
|
|
333
|
+
path = autostart.enable(["--hands-free"] if args.hands_free else [])
|
|
334
|
+
time.sleep(1.5)
|
|
335
|
+
st = autostart.status()
|
|
336
|
+
print(f"login item: {path}")
|
|
337
|
+
running = f" and is running now (pid {st['pid']})" if st.get("pid") else ""
|
|
338
|
+
print(f"softvoice app starts at every login{running}. Output: {autostart.APP_LOG}")
|
|
339
|
+
print("If macOS asks for the microphone, Accessibility or Input Monitoring again, that is because the app")
|
|
340
|
+
print("now runs under Python instead of your terminal. Grant them once and they stick.")
|
|
341
|
+
return 0
|
|
342
|
+
if args.action == "off":
|
|
343
|
+
if autostart.disable():
|
|
344
|
+
print("login item removed and the app stopped. `softvoice app` starts it by hand.")
|
|
345
|
+
else:
|
|
346
|
+
print("no login item was installed.")
|
|
347
|
+
return 0
|
|
348
|
+
st = autostart.status()
|
|
349
|
+
if not st["installed"]:
|
|
350
|
+
print("not installed. `softvoice autostart on` makes the app start at login.")
|
|
351
|
+
else:
|
|
352
|
+
where = f"running (pid {st['pid']})" if st.get("pid") else ("loaded, not running" if st["loaded"] else "installed, not loaded")
|
|
353
|
+
print(f"login item {autostart.PLIST_PATH}: {where}")
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_transcribe(args) -> int:
|
|
358
|
+
from softvoice.stt import Transcriber
|
|
359
|
+
|
|
360
|
+
cfg = Config.load()
|
|
361
|
+
t = Transcriber(cfg.stt_model)
|
|
362
|
+
t0 = time.monotonic()
|
|
363
|
+
text = t.transcribe_file(Path(args.file))
|
|
364
|
+
print(text)
|
|
365
|
+
print(f"[{time.monotonic() - t0:.2f}s]", file=sys.stderr)
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
COMMANDS = {
|
|
370
|
+
"serve": cmd_serve,
|
|
371
|
+
"say": cmd_say,
|
|
372
|
+
"stop": cmd_simple("stop", reason="cli"),
|
|
373
|
+
"mute": cmd_simple("mute"),
|
|
374
|
+
"unmute": cmd_simple("unmute"),
|
|
375
|
+
"status": cmd_status,
|
|
376
|
+
"restart": cmd_restart,
|
|
377
|
+
"quit": cmd_simple("quit"),
|
|
378
|
+
"voice": cmd_voice,
|
|
379
|
+
"voices": cmd_voices,
|
|
380
|
+
"log": cmd_log,
|
|
381
|
+
"hook": cmd_hook,
|
|
382
|
+
"notify": cmd_notify,
|
|
383
|
+
"setup": cmd_setup,
|
|
384
|
+
"install": cmd_install,
|
|
385
|
+
"uninstall": cmd_uninstall,
|
|
386
|
+
"config": cmd_config,
|
|
387
|
+
"listen": cmd_listen,
|
|
388
|
+
"app": cmd_app,
|
|
389
|
+
"autostart": cmd_autostart,
|
|
390
|
+
"transcribe": cmd_transcribe,
|
|
391
|
+
}
|