saynow 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ node_modules/
2
+ *.tgz
3
+ .DS_Store
4
+
5
+ # python
6
+ __pycache__/
7
+ *.py[cod]
8
+ .venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
saynow-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: saynow
3
+ Version: 0.1.0
4
+ Summary: Speak text aloud from the terminal. Built for LLM agents to talk to you, with offline system TTS when no API key is configured.
5
+ Project-URL: Homepage, https://github.com/dhruvyad/saynow
6
+ Project-URL: Repository, https://github.com/dhruvyad/saynow
7
+ Project-URL: Issues, https://github.com/dhruvyad/saynow/issues
8
+ Author-email: Dhruv Yadav <dhruvyadav1806@gmail.com>
9
+ License: MIT
10
+ Keywords: agent,cli,llm,say,speech,text-to-speech,tts,voice
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # saynow
25
+
26
+ Speak text aloud from the terminal. Built so LLM agents can talk to you.
27
+
28
+ ```bash
29
+ saynow "the build finished, 42 tests passed"
30
+ ```
31
+
32
+ Works with no configuration at all — it falls back to your operating system's
33
+ built-in voice, which is offline, free, and needs no API key. Configure a cloud
34
+ provider when you want it to sound good.
35
+
36
+ ## Status: not published to PyPI
37
+
38
+ The shipping build of saynow is [the npm package](https://www.npmjs.com/package/saynow).
39
+ This Python port is a complete, working equivalent that lives in the repo but is
40
+ deliberately unpublished — maintaining two implementations in lockstep is a cost
41
+ worth paying only once someone actually needs the Python one.
42
+
43
+ The name is available on PyPI, so it can be published at any time. Open an issue
44
+ if you want it.
45
+
46
+ ## Install from source
47
+
48
+ ```bash
49
+ git clone https://github.com/dhruvyad/saynow
50
+ uv tool install ./saynow/python
51
+ ```
52
+
53
+ No third-party dependencies — it uses only the standard library. Both builds
54
+ read the same config file, so they are interchangeable on one machine.
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ saynow "text to speak" # speak an argument
60
+ echo "text" | saynow # speak stdin
61
+ pytest 2>&1 | tail -1 | saynow # speak the last line of a command
62
+ ```
63
+
64
+ | Flag | Meaning |
65
+ | --- | --- |
66
+ | `-v, --voice <name>` | Voice to use — see `saynow voices` |
67
+ | `-p, --provider <id>` | `system`, `openai`, or `elevenlabs` |
68
+ | `-r, --rate <n>` | Words per minute (system provider) |
69
+ | `-s, --speed <n>` | Playback speed 0.25–4.0 (cloud providers) |
70
+ | `--save <file>` | Write audio to a file instead of playing it |
71
+ | `--no-queue` | Speak immediately, overlapping in-flight speech |
72
+ | `--strict` | Fail rather than falling back to the system voice |
73
+ | `-q, --quiet` | Suppress warnings on stderr |
74
+
75
+ ## Configuration
76
+
77
+ ```bash
78
+ saynow init # interactive setup
79
+ saynow config list # show settings, API keys redacted
80
+ saynow config set provider openai
81
+ ```
82
+
83
+ Config is stored at `~/.config/saynow/config.json` with `0600` permissions.
84
+ Precedence is **defaults → config file → environment → flags**.
85
+
86
+ API keys are never accepted as command-line flags — argv is visible in shell
87
+ history and to `ps`.
88
+
89
+ ## Providers
90
+
91
+ | Provider | Quality | Cost | Needs |
92
+ | --- | --- | --- | --- |
93
+ | `system` | Fair | Free | Nothing — built into the OS |
94
+ | `openai` | Good | Paid | `OPENAI_API_KEY` |
95
+ | `elevenlabs` | Best | Paid | `ELEVENLABS_API_KEY` |
96
+
97
+ If a cloud provider is selected but no key is available, `saynow` warns on stderr
98
+ and speaks with the system voice anyway. Pass `--strict` to opt out.
99
+
100
+ ## Using it from an agent
101
+
102
+ Add this to your `CLAUDE.md`, `AGENTS.md`, or equivalent:
103
+
104
+ ```markdown
105
+ You can speak to the user out loud with `saynow "<text>"`. Use it for things
106
+ worth interrupting for: a long task finishing, a question that blocks progress,
107
+ or an error that needs attention. Keep it to one short sentence — it is spoken
108
+ aloud, not read. Do not narrate routine progress.
109
+ ```
110
+
111
+ Concurrent invocations are serialized with a lock file, so three calls in a row
112
+ produce three sentences rather than one muddle.
113
+
114
+ ## License
115
+
116
+ MIT — source at <https://github.com/dhruvyad/saynow>
saynow-0.1.0/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # saynow
2
+
3
+ Speak text aloud from the terminal. Built so LLM agents can talk to you.
4
+
5
+ ```bash
6
+ saynow "the build finished, 42 tests passed"
7
+ ```
8
+
9
+ Works with no configuration at all — it falls back to your operating system's
10
+ built-in voice, which is offline, free, and needs no API key. Configure a cloud
11
+ provider when you want it to sound good.
12
+
13
+ ## Status: not published to PyPI
14
+
15
+ The shipping build of saynow is [the npm package](https://www.npmjs.com/package/saynow).
16
+ This Python port is a complete, working equivalent that lives in the repo but is
17
+ deliberately unpublished — maintaining two implementations in lockstep is a cost
18
+ worth paying only once someone actually needs the Python one.
19
+
20
+ The name is available on PyPI, so it can be published at any time. Open an issue
21
+ if you want it.
22
+
23
+ ## Install from source
24
+
25
+ ```bash
26
+ git clone https://github.com/dhruvyad/saynow
27
+ uv tool install ./saynow/python
28
+ ```
29
+
30
+ No third-party dependencies — it uses only the standard library. Both builds
31
+ read the same config file, so they are interchangeable on one machine.
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ saynow "text to speak" # speak an argument
37
+ echo "text" | saynow # speak stdin
38
+ pytest 2>&1 | tail -1 | saynow # speak the last line of a command
39
+ ```
40
+
41
+ | Flag | Meaning |
42
+ | --- | --- |
43
+ | `-v, --voice <name>` | Voice to use — see `saynow voices` |
44
+ | `-p, --provider <id>` | `system`, `openai`, or `elevenlabs` |
45
+ | `-r, --rate <n>` | Words per minute (system provider) |
46
+ | `-s, --speed <n>` | Playback speed 0.25–4.0 (cloud providers) |
47
+ | `--save <file>` | Write audio to a file instead of playing it |
48
+ | `--no-queue` | Speak immediately, overlapping in-flight speech |
49
+ | `--strict` | Fail rather than falling back to the system voice |
50
+ | `-q, --quiet` | Suppress warnings on stderr |
51
+
52
+ ## Configuration
53
+
54
+ ```bash
55
+ saynow init # interactive setup
56
+ saynow config list # show settings, API keys redacted
57
+ saynow config set provider openai
58
+ ```
59
+
60
+ Config is stored at `~/.config/saynow/config.json` with `0600` permissions.
61
+ Precedence is **defaults → config file → environment → flags**.
62
+
63
+ API keys are never accepted as command-line flags — argv is visible in shell
64
+ history and to `ps`.
65
+
66
+ ## Providers
67
+
68
+ | Provider | Quality | Cost | Needs |
69
+ | --- | --- | --- | --- |
70
+ | `system` | Fair | Free | Nothing — built into the OS |
71
+ | `openai` | Good | Paid | `OPENAI_API_KEY` |
72
+ | `elevenlabs` | Best | Paid | `ELEVENLABS_API_KEY` |
73
+
74
+ If a cloud provider is selected but no key is available, `saynow` warns on stderr
75
+ and speaks with the system voice anyway. Pass `--strict` to opt out.
76
+
77
+ ## Using it from an agent
78
+
79
+ Add this to your `CLAUDE.md`, `AGENTS.md`, or equivalent:
80
+
81
+ ```markdown
82
+ You can speak to the user out loud with `saynow "<text>"`. Use it for things
83
+ worth interrupting for: a long task finishing, a question that blocks progress,
84
+ or an error that needs attention. Keep it to one short sentence — it is spoken
85
+ aloud, not read. Do not narrate routine progress.
86
+ ```
87
+
88
+ Concurrent invocations are serialized with a lock file, so three calls in a row
89
+ produce three sentences rather than one muddle.
90
+
91
+ ## License
92
+
93
+ MIT — source at <https://github.com/dhruvyad/saynow>
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "saynow"
7
+ version = "0.1.0"
8
+ description = "Speak text aloud from the terminal. Built for LLM agents to talk to you, with offline system TTS when no API key is configured."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dhruv Yadav", email = "dhruvyadav1806@gmail.com" }]
13
+ keywords = ["tts", "text-to-speech", "cli", "speech", "say", "voice", "agent", "llm"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
24
+ "Topic :: Utilities",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/dhruvyad/saynow"
30
+ Repository = "https://github.com/dhruvyad/saynow"
31
+ Issues = "https://github.com/dhruvyad/saynow/issues"
32
+
33
+ [project.scripts]
34
+ saynow = "saynow.cli:main"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/saynow"]
@@ -0,0 +1,5 @@
1
+ """saynow — speak text aloud from the terminal."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,101 @@
1
+ """Audio playback and cross-process serialization of speech."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+
15
+ PLAYERS = {
16
+ "darwin": [("afplay", lambda f: [f])],
17
+ "linux": [
18
+ ("ffplay", lambda f: ["-nodisp", "-autoexit", "-loglevel", "quiet", f]),
19
+ ("mpv", lambda f: ["--no-video", "--really-quiet", f]),
20
+ ("mpg123", lambda f: ["-q", f]),
21
+ ("paplay", lambda f: [f]),
22
+ ("aplay", lambda f: ["-q", f]),
23
+ ],
24
+ }
25
+
26
+ LOCK_PATH = Path(tempfile.gettempdir()) / "saynow.lock"
27
+ STALE_SECONDS = 300
28
+
29
+
30
+ def find_player():
31
+ for name, args in PLAYERS.get(sys.platform, []):
32
+ if shutil.which(name):
33
+ return name, args
34
+ return None
35
+
36
+
37
+ def play(path: str) -> None:
38
+ found = find_player()
39
+ if not found:
40
+ tried = ", ".join(n for n, _ in PLAYERS.get(sys.platform, [])) or sys.platform
41
+ raise SystemExit(
42
+ f"saynow: no audio player found (looked for: {tried}).\n"
43
+ "Install one, or use --save <file> to write the audio instead."
44
+ )
45
+ name, args = found
46
+ result = subprocess.run([name, *args(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
47
+ if result.returncode != 0:
48
+ raise SystemExit(f"saynow: {name} exited with code {result.returncode}")
49
+
50
+
51
+ def _alive(pid: int) -> bool:
52
+ try:
53
+ os.kill(pid, 0)
54
+ return True
55
+ except ProcessLookupError:
56
+ return False
57
+ except PermissionError:
58
+ return True
59
+
60
+
61
+ def _clear_if_stale() -> None:
62
+ try:
63
+ holder = json.loads(LOCK_PATH.read_text(encoding="utf-8"))
64
+ except FileNotFoundError:
65
+ return
66
+ except (json.JSONDecodeError, OSError):
67
+ LOCK_PATH.unlink(missing_ok=True)
68
+ return
69
+
70
+ expired = time.time() - holder.get("at", 0) > STALE_SECONDS
71
+ if expired or not _alive(holder.get("pid", -1)):
72
+ LOCK_PATH.unlink(missing_ok=True)
73
+
74
+
75
+ @contextmanager
76
+ def queued(enabled: bool = True, timeout: float = 120.0):
77
+ """Serialize playback so concurrent invocations never overlap."""
78
+ if not enabled:
79
+ yield
80
+ return
81
+
82
+ deadline = time.time() + timeout
83
+ while True:
84
+ try:
85
+ fd = os.open(str(LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
86
+ os.write(fd, json.dumps({"pid": os.getpid(), "at": time.time()}).encode())
87
+ os.close(fd)
88
+ break
89
+ except FileExistsError:
90
+ _clear_if_stale()
91
+ if time.time() > deadline:
92
+ raise SystemExit(
93
+ f"saynow: timed out waiting for another saynow to finish (lock: {LOCK_PATH}).\n"
94
+ "Pass --no-queue to speak immediately without waiting."
95
+ )
96
+ time.sleep(0.06)
97
+
98
+ try:
99
+ yield
100
+ finally:
101
+ LOCK_PATH.unlink(missing_ok=True)
@@ -0,0 +1,255 @@
1
+ """Command-line interface for saynow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from . import __version__, config as cfg, providers
13
+ from .audio import play, queued
14
+
15
+ SUBCOMMANDS = {"init", "config", "voices", "help"}
16
+
17
+ EPILOG = f"""
18
+ config:
19
+ saynow config list Show current settings (keys redacted)
20
+ saynow config get <key>
21
+ saynow config set <key> <val>
22
+ saynow config path Print the config file location
23
+
24
+ Config lives at {cfg.config_path()} with 0600 permissions.
25
+ Precedence: defaults < config file < environment < flags.
26
+ Keys: provider, voice, model, speed, openaiApiKey, elevenlabsApiKey
27
+ Env: SAYNOW_PROVIDER, SAYNOW_VOICE, SAYNOW_MODEL, SAYNOW_SPEED,
28
+ OPENAI_API_KEY, ELEVENLABS_API_KEY
29
+
30
+ notes:
31
+ With no provider configured, saynow uses the built-in OS voice — offline,
32
+ no API key, works out of the box. Configure a cloud provider for better
33
+ audio quality. Concurrent invocations are queued so speech never overlaps.
34
+
35
+ examples:
36
+ saynow "the build finished"
37
+ saynow -p openai -v nova "tests passed, 42 of 42"
38
+ saynow --save note.mp3 "long form text"
39
+ pytest 2>&1 | tail -1 | saynow
40
+ """
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="saynow",
46
+ description="Speak text aloud from the terminal.",
47
+ epilog=EPILOG,
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ allow_abbrev=False,
50
+ )
51
+ parser.add_argument("text", nargs="*", help="text to speak (or pipe it on stdin)")
52
+ parser.add_argument("-v", "--voice", help="voice to use (see: saynow voices)")
53
+ parser.add_argument(
54
+ "-p", "--provider", choices=sorted(providers.PROVIDERS), help="speech provider"
55
+ )
56
+ parser.add_argument("-r", "--rate", type=float, help="words per minute (system provider)")
57
+ parser.add_argument("-s", "--speed", type=float, help="speed 0.25-4.0 (cloud providers)")
58
+ parser.add_argument("--save", metavar="FILE", help="write audio to a file instead of playing")
59
+ parser.add_argument(
60
+ "--no-queue", action="store_true", help="speak immediately, overlapping in-flight speech"
61
+ )
62
+ parser.add_argument(
63
+ "--strict", action="store_true", help="fail instead of falling back to the system voice"
64
+ )
65
+ parser.add_argument("-q", "--quiet", action="store_true", help="suppress warnings on stderr")
66
+ parser.add_argument("--version", action="version", version=__version__)
67
+ return parser
68
+
69
+
70
+ def main(argv: Optional[List[str]] = None) -> int:
71
+ parser = build_parser()
72
+ args = parser.parse_args(argv)
73
+
74
+ if args.text and args.text[0] in SUBCOMMANDS:
75
+ command, rest = args.text[0], args.text[1:]
76
+ if command == "help":
77
+ parser.print_help()
78
+ return 0
79
+ if command == "init":
80
+ return init_command()
81
+ if command == "config":
82
+ return config_command(rest)
83
+ if command == "voices":
84
+ return voices_command(args)
85
+
86
+ text = " ".join(args.text) if args.text else read_stdin()
87
+ if not text.strip():
88
+ parser.error("nothing to say. Pass text as an argument or pipe it on stdin.")
89
+
90
+ return speak(text.strip(), args)
91
+
92
+
93
+ def read_stdin() -> str:
94
+ if sys.stdin.isatty():
95
+ return ""
96
+ return sys.stdin.read()
97
+
98
+
99
+ def speak(text: str, args: argparse.Namespace) -> int:
100
+ """Always make a sound: degrade to the offline voice rather than failing."""
101
+ config = cfg.resolve(
102
+ provider=args.provider, voice=args.voice, model=None, speed=args.speed
103
+ )
104
+ provider_id = config["provider"]
105
+ selected = providers.get(provider_id)
106
+ key = cfg.api_key(provider_id, config)
107
+
108
+ if not selected["speaks_directly"] and not key:
109
+ if args.strict:
110
+ raise SystemExit(
111
+ f"saynow: provider \"{provider_id}\" needs a key but none was found. "
112
+ f"Set {selected['env_var']} or run: saynow init"
113
+ )
114
+ warn(
115
+ args,
116
+ f"no {selected['env_var']} found — using the offline system voice. "
117
+ f"Run `saynow init` to configure {provider_id}.",
118
+ )
119
+ provider_id, key = "system", None
120
+
121
+ if provider_id == "system":
122
+ if args.save:
123
+ raise SystemExit(
124
+ "saynow: --save is not supported by the system provider. "
125
+ "Configure openai or elevenlabs to write audio files."
126
+ )
127
+ with queued(enabled=not args.no_queue):
128
+ providers.speak_system(text, voice=config.get("voice"), rate=args.rate)
129
+ return 0
130
+
131
+ audio = providers.SYNTHESIZERS[provider_id](
132
+ text,
133
+ api_key=key,
134
+ voice=config.get("voice"),
135
+ model=config.get("model"),
136
+ speed=config.get("speed"),
137
+ )
138
+
139
+ if args.save:
140
+ Path(args.save).write_bytes(audio)
141
+ if not args.quiet:
142
+ print(os.path.abspath(args.save))
143
+ return 0
144
+
145
+ tmp = Path(tempfile.gettempdir()) / f"saynow-{os.getpid()}.mp3"
146
+ tmp.write_bytes(audio)
147
+ os.chmod(tmp, 0o600)
148
+ try:
149
+ with queued(enabled=not args.no_queue):
150
+ play(str(tmp))
151
+ finally:
152
+ tmp.unlink(missing_ok=True)
153
+ return 0
154
+
155
+
156
+ def init_command() -> int:
157
+ print("Configure saynow. Press enter to keep the current value.\n")
158
+ for name, meta in providers.PROVIDERS.items():
159
+ print(f" {name:<12} {meta['label']}")
160
+ print()
161
+
162
+ config = cfg.load()
163
+ chosen = input(f"Provider [{config['provider']}]: ").strip() or config["provider"]
164
+ meta = providers.get(chosen)
165
+ config["provider"] = chosen
166
+
167
+ if not meta["speaks_directly"]:
168
+ existing = config.get(meta["config_key"])
169
+ prompt = (
170
+ f"API key [{cfg.redact(existing)}]: "
171
+ if existing
172
+ else f"API key (or leave blank to use ${meta['env_var']}): "
173
+ )
174
+ entered = input(prompt).strip()
175
+ if entered:
176
+ config[meta["config_key"]] = entered
177
+
178
+ voice = input(f"Voice [{config.get('voice') or 'default'}]: ").strip()
179
+ if voice:
180
+ config["voice"] = voice
181
+
182
+ cfg.save(config)
183
+ print(f"\nSaved to {cfg.config_path()} (mode 0600).")
184
+
185
+ if not meta["speaks_directly"] and not cfg.api_key(chosen, config):
186
+ print(
187
+ f"\nNo key stored and ${meta['env_var']} is unset — "
188
+ "saynow will use the offline system voice until one is available."
189
+ )
190
+ return 0
191
+
192
+
193
+ def config_command(rest: List[str]) -> int:
194
+ action = rest[0] if rest else "list"
195
+ config: Dict[str, Any] = cfg.load()
196
+
197
+ if action == "path":
198
+ print(cfg.config_path())
199
+ return 0
200
+
201
+ if action == "list":
202
+ width = max(len(k) for k in config)
203
+ for key, value in config.items():
204
+ shown = cfg.redact(value) if key in cfg.SECRET_KEYS else value
205
+ print(f"{key:<{width}} {shown}")
206
+ if not cfg.config_path().exists():
207
+ print("\n(no config file yet — these are defaults. Run `saynow init`.)")
208
+ return 0
209
+
210
+ if action == "get":
211
+ if len(rest) < 2:
212
+ raise SystemExit("saynow: usage: saynow config get <key>")
213
+ key = rest[1]
214
+ if key not in config:
215
+ raise SystemExit(f"saynow: no such key: {key}")
216
+ print(cfg.redact(config[key]) if key in cfg.SECRET_KEYS else config[key])
217
+ return 0
218
+
219
+ if action == "set":
220
+ if len(rest) < 3:
221
+ raise SystemExit("saynow: usage: saynow config set <key> <value>")
222
+ key, value = rest[1], " ".join(rest[2:])
223
+ if key == "provider":
224
+ providers.get(value)
225
+ config[key] = float(value) if key in {"speed", "rate"} else value
226
+ cfg.save(config)
227
+ shown = cfg.redact(value) if key in cfg.SECRET_KEYS else config[key]
228
+ print(f"{key} = {shown}")
229
+ return 0
230
+
231
+ raise SystemExit(f"saynow: unknown config command \"{action}\". Use: list, get, set, path")
232
+
233
+
234
+ def voices_command(args: argparse.Namespace) -> int:
235
+ config = cfg.resolve(provider=args.provider)
236
+ provider_id = config["provider"]
237
+ listed = providers.voices(provider_id, cfg.api_key(provider_id, config))
238
+
239
+ if not listed:
240
+ print(f"No voices listed for provider \"{provider_id}\".")
241
+ return 0
242
+
243
+ width = max(len(name) for name, _, _ in listed)
244
+ for name, locale, note in listed:
245
+ print(f"{name:<{width}} {locale:<8} {note}".rstrip())
246
+ return 0
247
+
248
+
249
+ def warn(args: argparse.Namespace, message: str) -> None:
250
+ if not args.quiet:
251
+ print(f"saynow: {message}", file=sys.stderr)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ sys.exit(main())
@@ -0,0 +1,91 @@
1
+ """Configuration storage, shared on disk with the npm build of saynow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ SECRET_KEYS = {"openaiApiKey", "elevenlabsApiKey"}
11
+
12
+ DEFAULTS: Dict[str, Any] = {
13
+ "provider": "system",
14
+ "voice": None,
15
+ "model": None,
16
+ "speed": 1,
17
+ }
18
+
19
+
20
+ def config_dir() -> Path:
21
+ override = os.environ.get("SAYNOW_CONFIG_DIR")
22
+ if override:
23
+ return Path(override).expanduser().resolve()
24
+ return Path.home() / ".config" / "saynow"
25
+
26
+
27
+ def config_path() -> Path:
28
+ return config_dir() / "config.json"
29
+
30
+
31
+ def load() -> Dict[str, Any]:
32
+ path = config_path()
33
+ try:
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ except FileNotFoundError:
36
+ return dict(DEFAULTS)
37
+ except json.JSONDecodeError as err:
38
+ raise SystemExit(f"saynow: config at {path} is not valid JSON: {err}")
39
+ merged = dict(DEFAULTS)
40
+ merged.update(data)
41
+ return merged
42
+
43
+
44
+ def save(config: Dict[str, Any]) -> None:
45
+ directory = config_dir()
46
+ directory.mkdir(parents=True, exist_ok=True)
47
+ os.chmod(directory, 0o700)
48
+
49
+ path = config_path()
50
+ # Write to a temp file first so a crash can't truncate an existing config.
51
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
52
+ tmp.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
53
+ os.chmod(tmp, 0o600)
54
+ tmp.replace(path)
55
+ os.chmod(path, 0o600)
56
+
57
+
58
+ def resolve(**flags: Any) -> Dict[str, Any]:
59
+ """Layer the sources: defaults < config file < environment < CLI flags."""
60
+ config = load()
61
+
62
+ env_map = {
63
+ "provider": "SAYNOW_PROVIDER",
64
+ "voice": "SAYNOW_VOICE",
65
+ "model": "SAYNOW_MODEL",
66
+ "speed": "SAYNOW_SPEED",
67
+ }
68
+ for key, var in env_map.items():
69
+ value = os.environ.get(var)
70
+ if value:
71
+ config[key] = float(value) if key == "speed" else value
72
+
73
+ config.update({k: v for k, v in flags.items() if v is not None})
74
+ return config
75
+
76
+
77
+ def api_key(provider: str, config: Optional[Dict[str, Any]] = None) -> Optional[str]:
78
+ """Environment wins over the config file so a shell can override it."""
79
+ if config is None:
80
+ config = load()
81
+ if provider == "openai":
82
+ return os.environ.get("OPENAI_API_KEY") or config.get("openaiApiKey")
83
+ if provider == "elevenlabs":
84
+ return os.environ.get("ELEVENLABS_API_KEY") or config.get("elevenlabsApiKey")
85
+ return None
86
+
87
+
88
+ def redact(value: Any) -> str:
89
+ if not isinstance(value, str) or len(value) <= 8:
90
+ return "****"
91
+ return f"{value[:4]}…{value[-4:]}"
@@ -0,0 +1,226 @@
1
+ """Speech providers: the offline system voice plus cloud backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ OPENAI_VOICES = [
14
+ "alloy", "ash", "ballad", "coral", "echo",
15
+ "fable", "nova", "onyx", "sage", "shimmer",
16
+ ]
17
+
18
+ ELEVENLABS_DEFAULT_VOICE = "21m00Tcm4TlvDq8ikWAM" # "Rachel", the default public voice
19
+
20
+ PROVIDERS: Dict[str, Dict[str, Any]] = {
21
+ "system": {
22
+ "label": "built-in OS speech synthesis (offline, no API key)",
23
+ "speaks_directly": True,
24
+ "env_var": None,
25
+ "config_key": None,
26
+ },
27
+ "openai": {
28
+ "label": "OpenAI /v1/audio/speech",
29
+ "speaks_directly": False,
30
+ "env_var": "OPENAI_API_KEY",
31
+ "config_key": "openaiApiKey",
32
+ },
33
+ "elevenlabs": {
34
+ "label": "ElevenLabs text-to-speech",
35
+ "speaks_directly": False,
36
+ "env_var": "ELEVENLABS_API_KEY",
37
+ "config_key": "elevenlabsApiKey",
38
+ },
39
+ }
40
+
41
+
42
+ def get(provider_id: str) -> Dict[str, Any]:
43
+ if provider_id not in PROVIDERS:
44
+ raise SystemExit(
45
+ f"saynow: unknown provider \"{provider_id}\". "
46
+ f"Available: {', '.join(PROVIDERS)}"
47
+ )
48
+ return PROVIDERS[provider_id]
49
+
50
+
51
+ # --- system -----------------------------------------------------------------
52
+
53
+ def _system_command(text: str, voice: Optional[str], rate: Optional[float]) -> List[str]:
54
+ if sys.platform == "darwin":
55
+ cmd = ["say"]
56
+ if voice:
57
+ cmd += ["-v", voice]
58
+ if rate:
59
+ cmd += ["-r", str(int(rate))]
60
+ return cmd + ["--", text]
61
+
62
+ if sys.platform.startswith("linux"):
63
+ binary = "espeak-ng" if shutil.which("espeak-ng") else "espeak"
64
+ cmd = [binary]
65
+ if voice:
66
+ cmd += ["-v", voice]
67
+ if rate:
68
+ cmd += ["-s", str(int(rate))]
69
+ return cmd + ["--", text]
70
+
71
+ if sys.platform == "win32":
72
+ escaped = text.replace("'", "''")
73
+ script = (
74
+ "Add-Type -AssemblyName System.Speech;"
75
+ "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
76
+ )
77
+ if voice:
78
+ script += f"$s.SelectVoice('{voice}');"
79
+ if rate:
80
+ script += f"$s.Rate = {max(-10, min(10, int(rate)))};"
81
+ script += f"$s.Speak('{escaped}')"
82
+ return ["powershell", "-NoProfile", "-Command", script]
83
+
84
+ raise SystemExit(f"saynow: no system speech backend known for platform \"{sys.platform}\"")
85
+
86
+
87
+ def speak_system(text: str, voice: Optional[str] = None, rate: Optional[float] = None) -> None:
88
+ cmd = _system_command(text, voice, rate)
89
+ try:
90
+ result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
91
+ except FileNotFoundError:
92
+ raise SystemExit(
93
+ f"saynow: \"{cmd[0]}\" not found. On Linux install espeak-ng "
94
+ "(apt install espeak-ng), or configure a cloud provider with: saynow init"
95
+ )
96
+ if result.returncode != 0:
97
+ raise SystemExit(f"saynow: {cmd[0]} exited with code {result.returncode}")
98
+
99
+
100
+ def system_voices() -> List[Tuple[str, str, str]]:
101
+ if sys.platform == "darwin":
102
+ out = subprocess.run(["say", "-v", "?"], capture_output=True, text=True)
103
+ if out.returncode != 0:
104
+ return []
105
+ voices = []
106
+ for line in out.stdout.splitlines():
107
+ parts = line.split("#", 1)
108
+ head = parts[0].rstrip()
109
+ note = parts[1].strip() if len(parts) > 1 else ""
110
+ bits = head.rsplit(None, 1)
111
+ if len(bits) == 2:
112
+ voices.append((bits[0].strip(), bits[1], note))
113
+ return voices
114
+
115
+ if sys.platform.startswith("linux"):
116
+ binary = "espeak-ng" if shutil.which("espeak-ng") else "espeak"
117
+ out = subprocess.run([binary, "--voices"], capture_output=True, text=True)
118
+ if out.returncode != 0:
119
+ return []
120
+ voices = []
121
+ for line in out.stdout.splitlines()[1:]:
122
+ parts = line.split()
123
+ if len(parts) >= 4:
124
+ voices.append((parts[3], parts[1], ""))
125
+ return voices
126
+
127
+ return []
128
+
129
+
130
+ # --- cloud ------------------------------------------------------------------
131
+
132
+ def _post(url: str, headers: Dict[str, str], body: Dict[str, Any], provider: str) -> bytes:
133
+ request = urllib.request.Request(
134
+ url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST"
135
+ )
136
+ try:
137
+ with urllib.request.urlopen(request) as response:
138
+ return response.read()
139
+ except urllib.error.HTTPError as err:
140
+ detail = err.read().decode("utf-8", "replace")[:300]
141
+ if err.code == 401:
142
+ raise SystemExit(
143
+ f"saynow: {provider} rejected the API key (401). Check it with: saynow config list"
144
+ )
145
+ if err.code == 429:
146
+ raise SystemExit(f"saynow: {provider} rate limited or out of quota (429). {detail}")
147
+ raise SystemExit(f"saynow: {provider} request failed ({err.code}). {detail}")
148
+ except urllib.error.URLError as err:
149
+ raise SystemExit(f"saynow: could not reach {provider}: {err.reason}")
150
+
151
+
152
+ def synthesize_openai(
153
+ text: str,
154
+ api_key: str,
155
+ voice: Optional[str] = None,
156
+ model: Optional[str] = None,
157
+ speed: Optional[float] = None,
158
+ ) -> bytes:
159
+ body: Dict[str, Any] = {
160
+ "model": model or "gpt-4o-mini-tts",
161
+ "voice": voice or "alloy",
162
+ "input": text,
163
+ "response_format": "mp3",
164
+ }
165
+ # Only the tts-1 family accepts `speed`; sending it otherwise is a 400.
166
+ if speed and speed != 1:
167
+ body["speed"] = speed
168
+
169
+ return _post(
170
+ "https://api.openai.com/v1/audio/speech",
171
+ {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
172
+ body,
173
+ "OpenAI",
174
+ )
175
+
176
+
177
+ def synthesize_elevenlabs(
178
+ text: str,
179
+ api_key: str,
180
+ voice: Optional[str] = None,
181
+ model: Optional[str] = None,
182
+ speed: Optional[float] = None,
183
+ ) -> bytes:
184
+ body: Dict[str, Any] = {"text": text, "model_id": model or "eleven_turbo_v2_5"}
185
+ if speed and speed != 1:
186
+ body["voice_settings"] = {"speed": speed}
187
+
188
+ return _post(
189
+ f"https://api.elevenlabs.io/v1/text-to-speech/{voice or ELEVENLABS_DEFAULT_VOICE}",
190
+ {"xi-api-key": api_key, "Content-Type": "application/json", "Accept": "audio/mpeg"},
191
+ body,
192
+ "ElevenLabs",
193
+ )
194
+
195
+
196
+ SYNTHESIZERS = {
197
+ "openai": synthesize_openai,
198
+ "elevenlabs": synthesize_elevenlabs,
199
+ }
200
+
201
+
202
+ def voices(provider_id: str, api_key: Optional[str] = None) -> List[Tuple[str, str, str]]:
203
+ if provider_id == "system":
204
+ return system_voices()
205
+ if provider_id == "openai":
206
+ return [(name, "multi", "") for name in OPENAI_VOICES]
207
+ if provider_id == "elevenlabs":
208
+ if not api_key:
209
+ return []
210
+ request = urllib.request.Request(
211
+ "https://api.elevenlabs.io/v1/voices", headers={"xi-api-key": api_key}
212
+ )
213
+ try:
214
+ with urllib.request.urlopen(request) as response:
215
+ payload = json.loads(response.read())
216
+ except (urllib.error.URLError, json.JSONDecodeError):
217
+ return []
218
+ return [
219
+ (
220
+ f"{v['name']} ({v['voice_id']})",
221
+ (v.get("labels") or {}).get("accent", "multi"),
222
+ (v.get("labels") or {}).get("description", ""),
223
+ )
224
+ for v in payload.get("voices", [])
225
+ ]
226
+ return []