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.
- saynow-0.1.0/.gitignore +11 -0
- saynow-0.1.0/PKG-INFO +116 -0
- saynow-0.1.0/README.md +93 -0
- saynow-0.1.0/pyproject.toml +37 -0
- saynow-0.1.0/src/saynow/__init__.py +5 -0
- saynow-0.1.0/src/saynow/audio.py +101 -0
- saynow-0.1.0/src/saynow/cli.py +255 -0
- saynow-0.1.0/src/saynow/config.py +91 -0
- saynow-0.1.0/src/saynow/providers.py +226 -0
saynow-0.1.0/.gitignore
ADDED
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,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 []
|