speak-cli 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.
- speak_cli-0.1.0/.github/workflows/publish.yml +27 -0
- speak_cli-0.1.0/.gitignore +8 -0
- speak_cli-0.1.0/.python-version +1 -0
- speak_cli-0.1.0/PKG-INFO +96 -0
- speak_cli-0.1.0/README.md +73 -0
- speak_cli-0.1.0/pyproject.toml +44 -0
- speak_cli-0.1.0/src/speak_cli/__init__.py +1 -0
- speak_cli-0.1.0/src/speak_cli/cli.py +177 -0
- speak_cli-0.1.0/src/speak_cli/daemon.py +103 -0
- speak_cli-0.1.0/src/speak_cli/engine.py +70 -0
- speak_cli-0.1.0/src/speak_cli/ipc.py +155 -0
- speak_cli-0.1.0/src/speak_cli/langdetect.py +69 -0
- speak_cli-0.1.0/src/speak_cli/playback.py +61 -0
- speak_cli-0.1.0/src/speak_cli/voices.py +55 -0
- speak_cli-0.1.0/tests/test_ipc_paths.py +18 -0
- speak_cli-0.1.0/tests/test_langdetect.py +58 -0
- speak_cli-0.1.0/tests/test_voices.py +41 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv sync
|
|
15
|
+
- run: uv run pytest -q
|
|
16
|
+
|
|
17
|
+
publish:
|
|
18
|
+
needs: test
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
environment: pypi
|
|
21
|
+
permissions:
|
|
22
|
+
id-token: write # PyPI trusted publishing
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: astral-sh/setup-uv@v5
|
|
26
|
+
- run: uv build
|
|
27
|
+
- run: uv publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
speak_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: speak-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Speak text out loud from the command line using Supertonic 3 (local, offline TTS)
|
|
5
|
+
Project-URL: Repository, https://github.com/MohamedAliRashad/tts-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/MohamedAliRashad/tts-cli/issues
|
|
7
|
+
Author: Mohamed Rashad
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: cli,offline,speech,supertonic,text-to-speech,tts
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: supertonic>=1.3.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# speak — local text-to-speech CLI
|
|
25
|
+
|
|
26
|
+
Speak text out loud from the command line using [Supertonic 3](https://huggingface.co/Supertone/supertonic-3) — a fast, ~99M-parameter TTS model that runs entirely on your CPU. No cloud, no API keys.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
speak "hello world"
|
|
30
|
+
echo "piped text works too" | speak
|
|
31
|
+
speak -v noah "a different voice"
|
|
32
|
+
speak "مرحبا بالعالم" # language auto-detected from the text
|
|
33
|
+
speak -o clip.wav "save to a file instead"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv tool install speak-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or without waiting for a PyPI release, straight from GitHub:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install git+https://github.com/MohamedAliRashad/tts-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
(From a clone, `uv tool install .` works too.) All dependencies are prebuilt Python wheels — no compilers, no apt packages. On the first `speak`, the Supertonic 3 models (~400 MB) are downloaded once to `~/.cache/supertonic3/`; everything after that works fully offline. `say` is installed as an alias of `speak` (handy, but if another tool on your machine already provides `say`, just use `speak`).
|
|
49
|
+
|
|
50
|
+
## How it stays fast
|
|
51
|
+
|
|
52
|
+
The first call starts a background daemon that keeps the models loaded in memory, so subsequent calls speak in well under a second. The daemon exits after 15 minutes idle (tune with `SPEAK_IDLE_TIMEOUT` seconds) and is respawned transparently. If the daemon can't run for any reason, `speak` silently falls back to in-process synthesis — it always works.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
speak --stop # stop the daemon manually
|
|
56
|
+
speak --no-daemon # bypass the daemon for one call
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Voices
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
speak --list-voices
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Female | Male |
|
|
66
|
+
|---|---|
|
|
67
|
+
| sara *(default)* | james |
|
|
68
|
+
| emma | daniel |
|
|
69
|
+
| lily | leo |
|
|
70
|
+
| maya | ryan |
|
|
71
|
+
| nora | noah |
|
|
72
|
+
|
|
73
|
+
`--voice` accepts any of these names, case-insensitive. Set a persistent default with `export SPEAK_VOICE=noah`.
|
|
74
|
+
|
|
75
|
+
## Options
|
|
76
|
+
|
|
77
|
+
| Flag | Default | Meaning |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `-v, --voice` | sara | voice name |
|
|
80
|
+
| `-s, --speed` | 1.05 | speech speed (0.7–2.0) |
|
|
81
|
+
| `-l, --lang` | auto | language code, `auto`, or `na` (language-agnostic) |
|
|
82
|
+
| `--steps` | 8 | quality/speed trade-off (5–12) |
|
|
83
|
+
| `-o, --out FILE` | — | write WAV instead of playing (`--play` for both) |
|
|
84
|
+
| `--verbose` | — | show detected language, timing, synthesis path |
|
|
85
|
+
|
|
86
|
+
Language auto-detection is script-based: Arabic, Japanese, Korean, Russian, Greek, and Hindi are detected from their alphabets; all Latin-script text is assumed English (use `--lang fr`, `--lang de`, … to override). Supported languages: en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv nl pl pt ro ru sk sl sv tr uk vi.
|
|
87
|
+
|
|
88
|
+
Expression tags can be embedded in the text: `speak "well <laugh> that was funny"` (also `<breath>`, `<sigh>`, …).
|
|
89
|
+
|
|
90
|
+
## Playback
|
|
91
|
+
|
|
92
|
+
Audio is played through the first working system player among `paplay`, `pw-play`, `aplay`, `afplay` (macOS), `ffplay`, `play` (sox) — every mainstream desktop has at least one. If none works, `speak` tells you and suggests `--out`.
|
|
93
|
+
|
|
94
|
+
## Licenses
|
|
95
|
+
|
|
96
|
+
CLI code: MIT. Supertonic 3 model weights: [OpenRAIL-M](https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE) (via the `supertonic` SDK's auto-download).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# speak — local text-to-speech CLI
|
|
2
|
+
|
|
3
|
+
Speak text out loud from the command line using [Supertonic 3](https://huggingface.co/Supertone/supertonic-3) — a fast, ~99M-parameter TTS model that runs entirely on your CPU. No cloud, no API keys.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
speak "hello world"
|
|
7
|
+
echo "piped text works too" | speak
|
|
8
|
+
speak -v noah "a different voice"
|
|
9
|
+
speak "مرحبا بالعالم" # language auto-detected from the text
|
|
10
|
+
speak -o clip.wav "save to a file instead"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv tool install speak-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or without waiting for a PyPI release, straight from GitHub:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv tool install git+https://github.com/MohamedAliRashad/tts-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
(From a clone, `uv tool install .` works too.) All dependencies are prebuilt Python wheels — no compilers, no apt packages. On the first `speak`, the Supertonic 3 models (~400 MB) are downloaded once to `~/.cache/supertonic3/`; everything after that works fully offline. `say` is installed as an alias of `speak` (handy, but if another tool on your machine already provides `say`, just use `speak`).
|
|
26
|
+
|
|
27
|
+
## How it stays fast
|
|
28
|
+
|
|
29
|
+
The first call starts a background daemon that keeps the models loaded in memory, so subsequent calls speak in well under a second. The daemon exits after 15 minutes idle (tune with `SPEAK_IDLE_TIMEOUT` seconds) and is respawned transparently. If the daemon can't run for any reason, `speak` silently falls back to in-process synthesis — it always works.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
speak --stop # stop the daemon manually
|
|
33
|
+
speak --no-daemon # bypass the daemon for one call
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Voices
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
speak --list-voices
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Female | Male |
|
|
43
|
+
|---|---|
|
|
44
|
+
| sara *(default)* | james |
|
|
45
|
+
| emma | daniel |
|
|
46
|
+
| lily | leo |
|
|
47
|
+
| maya | ryan |
|
|
48
|
+
| nora | noah |
|
|
49
|
+
|
|
50
|
+
`--voice` accepts any of these names, case-insensitive. Set a persistent default with `export SPEAK_VOICE=noah`.
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
| Flag | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `-v, --voice` | sara | voice name |
|
|
57
|
+
| `-s, --speed` | 1.05 | speech speed (0.7–2.0) |
|
|
58
|
+
| `-l, --lang` | auto | language code, `auto`, or `na` (language-agnostic) |
|
|
59
|
+
| `--steps` | 8 | quality/speed trade-off (5–12) |
|
|
60
|
+
| `-o, --out FILE` | — | write WAV instead of playing (`--play` for both) |
|
|
61
|
+
| `--verbose` | — | show detected language, timing, synthesis path |
|
|
62
|
+
|
|
63
|
+
Language auto-detection is script-based: Arabic, Japanese, Korean, Russian, Greek, and Hindi are detected from their alphabets; all Latin-script text is assumed English (use `--lang fr`, `--lang de`, … to override). Supported languages: en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv nl pl pt ro ru sk sl sv tr uk vi.
|
|
64
|
+
|
|
65
|
+
Expression tags can be embedded in the text: `speak "well <laugh> that was funny"` (also `<breath>`, `<sigh>`, …).
|
|
66
|
+
|
|
67
|
+
## Playback
|
|
68
|
+
|
|
69
|
+
Audio is played through the first working system player among `paplay`, `pw-play`, `aplay`, `afplay` (macOS), `ffplay`, `play` (sox) — every mainstream desktop has at least one. If none works, `speak` tells you and suggests `--out`.
|
|
70
|
+
|
|
71
|
+
## Licenses
|
|
72
|
+
|
|
73
|
+
CLI code: MIT. Supertonic 3 model weights: [OpenRAIL-M](https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE) (via the `supertonic` SDK's auto-download).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "speak-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Speak text out loud from the command line using Supertonic 3 (local, offline TTS)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Mohamed Rashad" }]
|
|
9
|
+
keywords = ["tts", "text-to-speech", "supertonic", "cli", "speech", "offline"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"supertonic>=1.3.1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/MohamedAliRashad/tts-cli"
|
|
28
|
+
Issues = "https://github.com/MohamedAliRashad/tts-cli/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
say = "speak_cli.cli:main"
|
|
32
|
+
speak = "speak_cli.cli:main"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/speak_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""`speak` — speak text out loud using Supertonic 3, locally and offline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__, voices
|
|
9
|
+
from .langdetect import SUPPORTED_LANGS, detect_lang
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
p = argparse.ArgumentParser(
|
|
14
|
+
prog="speak",
|
|
15
|
+
description="Speak text out loud using Supertonic 3 (local TTS, no cloud).",
|
|
16
|
+
epilog='Examples: speak "hello world" | echo hi | speak | speak -v noah -l ar "مرحبا"',
|
|
17
|
+
)
|
|
18
|
+
p.add_argument("text", nargs="*", help="text to speak (reads stdin if omitted)")
|
|
19
|
+
p.add_argument("-v", "--voice", default=None, metavar="NAME",
|
|
20
|
+
help="voice name (see --list-voices; default: sara)")
|
|
21
|
+
p.add_argument("-s", "--speed", type=float, default=1.05, metavar="X",
|
|
22
|
+
help="speech speed, 0.7-2.0 (default: 1.05)")
|
|
23
|
+
p.add_argument("-l", "--lang", default="auto", metavar="CODE",
|
|
24
|
+
help='language code, "auto" (default) or "na"')
|
|
25
|
+
p.add_argument("--steps", type=int, default=8, metavar="N",
|
|
26
|
+
help="denoising steps 5-12, higher = better quality (default: 8)")
|
|
27
|
+
p.add_argument("-o", "--out", metavar="FILE",
|
|
28
|
+
help="save WAV to FILE instead of playing")
|
|
29
|
+
p.add_argument("--play", action="store_true",
|
|
30
|
+
help="with --out: also play the audio")
|
|
31
|
+
p.add_argument("--list-voices", action="store_true", help="list voices and exit")
|
|
32
|
+
p.add_argument("--no-daemon", action="store_true",
|
|
33
|
+
help="synthesize in-process instead of using the daemon")
|
|
34
|
+
p.add_argument("--stop", action="store_true",
|
|
35
|
+
help="stop the background daemon and exit")
|
|
36
|
+
p.add_argument("--verbose", action="store_true",
|
|
37
|
+
help="print timing and which synthesis/playback path was used")
|
|
38
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
39
|
+
return p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_text(args: argparse.Namespace) -> str:
|
|
43
|
+
if args.text:
|
|
44
|
+
return " ".join(args.text).strip()
|
|
45
|
+
if not sys.stdin.isatty():
|
|
46
|
+
return sys.stdin.read().strip()
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _synthesize(req: dict, no_daemon: bool, verbose: bool) -> bytes:
|
|
51
|
+
"""Get WAV bytes: daemon first, silent fallback to in-process."""
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
from . import ipc
|
|
55
|
+
from .engine import models_cached
|
|
56
|
+
|
|
57
|
+
first_run = not models_cached()
|
|
58
|
+
if first_run:
|
|
59
|
+
print(
|
|
60
|
+
"First run: downloading Supertonic 3 models (~400 MB, one time)...",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
t0 = time.monotonic()
|
|
65
|
+
if not no_daemon and not first_run:
|
|
66
|
+
try:
|
|
67
|
+
wav = ipc.get_wav_from_daemon(req)
|
|
68
|
+
if verbose:
|
|
69
|
+
print(f"[speak] daemon path, {time.monotonic() - t0:.2f}s", file=sys.stderr)
|
|
70
|
+
return wav
|
|
71
|
+
except Exception as e:
|
|
72
|
+
ipc.log_client(f"daemon path failed, falling back to one-shot: {e!r}")
|
|
73
|
+
if verbose:
|
|
74
|
+
print(f"[speak] daemon failed ({e}), one-shot fallback", file=sys.stderr)
|
|
75
|
+
|
|
76
|
+
from .engine import Engine
|
|
77
|
+
|
|
78
|
+
engine = Engine()
|
|
79
|
+
wav, _ = engine.synthesize(
|
|
80
|
+
text=req["text"],
|
|
81
|
+
voice=req["voice"],
|
|
82
|
+
speed=req["speed"],
|
|
83
|
+
lang=req["lang"],
|
|
84
|
+
steps=req["steps"],
|
|
85
|
+
)
|
|
86
|
+
if verbose:
|
|
87
|
+
print(f"[speak] one-shot path, {time.monotonic() - t0:.2f}s", file=sys.stderr)
|
|
88
|
+
if first_run and not no_daemon:
|
|
89
|
+
# Warm a daemon in the background so the next call is fast.
|
|
90
|
+
try:
|
|
91
|
+
from . import ipc as _ipc
|
|
92
|
+
|
|
93
|
+
_ipc.spawn_daemon(_ipc.runtime_paths())
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return wav
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: list[str] | None = None) -> int:
|
|
100
|
+
args = build_parser().parse_args(argv)
|
|
101
|
+
|
|
102
|
+
if args.list_voices:
|
|
103
|
+
print(voices.listing())
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
if args.stop:
|
|
107
|
+
from . import ipc
|
|
108
|
+
|
|
109
|
+
n = ipc.stop_daemons()
|
|
110
|
+
print(f"stopped {n} daemon(s)" if n else "no daemon running")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
text = _resolve_text(args)
|
|
114
|
+
if not text:
|
|
115
|
+
print("speak: no text to speak (pass text as arguments or pipe via stdin)",
|
|
116
|
+
file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
_, style_id = voices.resolve(args.voice or voices.default_voice())
|
|
121
|
+
except ValueError as e:
|
|
122
|
+
print(f"speak: {e}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
if not 0.7 <= args.speed <= 2.0:
|
|
126
|
+
print("speak: --speed must be between 0.7 and 2.0", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
if not 1 <= args.steps <= 32:
|
|
129
|
+
print("speak: --steps must be between 1 and 32", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
lang = detect_lang(text) if args.lang == "auto" else args.lang.lower()
|
|
133
|
+
if lang not in SUPPORTED_LANGS:
|
|
134
|
+
supported = " ".join(sorted(SUPPORTED_LANGS))
|
|
135
|
+
print(f"speak: unsupported language {lang!r}. Supported: {supported}",
|
|
136
|
+
file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
if args.verbose:
|
|
139
|
+
print(f"[speak] voice={style_id} lang={lang} speed={args.speed} steps={args.steps}",
|
|
140
|
+
file=sys.stderr)
|
|
141
|
+
|
|
142
|
+
req = {
|
|
143
|
+
"cmd": "speak",
|
|
144
|
+
"text": text,
|
|
145
|
+
"voice": style_id,
|
|
146
|
+
"speed": args.speed,
|
|
147
|
+
"lang": lang,
|
|
148
|
+
"steps": args.steps,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
wav = _synthesize(req, no_daemon=args.no_daemon, verbose=args.verbose)
|
|
153
|
+
|
|
154
|
+
if args.out:
|
|
155
|
+
with open(args.out, "wb") as f:
|
|
156
|
+
f.write(wav)
|
|
157
|
+
if args.verbose:
|
|
158
|
+
print(f"[speak] wrote {args.out}", file=sys.stderr)
|
|
159
|
+
|
|
160
|
+
if not args.out or args.play:
|
|
161
|
+
from .playback import PlaybackError, play
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
play(wav, verbose=args.verbose)
|
|
165
|
+
except PlaybackError as e:
|
|
166
|
+
print(f"speak: {e}", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
return 130
|
|
170
|
+
except Exception as e:
|
|
171
|
+
print(f"speak: {e}", file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Background daemon: keeps Supertonic models warm, serves synthesis requests.
|
|
2
|
+
|
|
3
|
+
Run as `python -m speak_cli.daemon`. Single instance is enforced with an
|
|
4
|
+
flock'd lock file; a second copy exits 0 immediately, so racing auto-spawns
|
|
5
|
+
are harmless. Exits on idle timeout to free memory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import ipc
|
|
18
|
+
from .engine import Engine
|
|
19
|
+
|
|
20
|
+
DEFAULT_IDLE_TIMEOUT = 900.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _idle_timeout() -> float:
|
|
24
|
+
try:
|
|
25
|
+
return max(5.0, float(os.environ["SPEAK_IDLE_TIMEOUT"]))
|
|
26
|
+
except (KeyError, ValueError):
|
|
27
|
+
return DEFAULT_IDLE_TIMEOUT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_request(conn: socket.socket, engine: Engine) -> bool:
|
|
31
|
+
"""Serve one connection. Returns False when asked to shut down."""
|
|
32
|
+
try:
|
|
33
|
+
req = json.loads(ipc.recv_line(conn))
|
|
34
|
+
cmd = req.get("cmd")
|
|
35
|
+
if cmd == "shutdown":
|
|
36
|
+
conn.sendall(b'{"status": "ok"}\n')
|
|
37
|
+
return False
|
|
38
|
+
if cmd == "ping":
|
|
39
|
+
conn.sendall(b'{"status": "ok"}\n')
|
|
40
|
+
return True
|
|
41
|
+
if cmd != "speak":
|
|
42
|
+
raise ValueError(f"unknown command {cmd!r}")
|
|
43
|
+
wav, duration = engine.synthesize(
|
|
44
|
+
text=req["text"],
|
|
45
|
+
voice=req.get("voice", "F1"),
|
|
46
|
+
speed=req.get("speed", 1.05),
|
|
47
|
+
lang=req.get("lang", "en"),
|
|
48
|
+
steps=req.get("steps", 8),
|
|
49
|
+
)
|
|
50
|
+
header = json.dumps(
|
|
51
|
+
{"status": "ok", "nbytes": len(wav), "duration": duration}
|
|
52
|
+
)
|
|
53
|
+
conn.sendall(header.encode() + b"\n" + wav)
|
|
54
|
+
except Exception as e: # one bad request must never kill the daemon
|
|
55
|
+
with contextlib.suppress(OSError):
|
|
56
|
+
conn.sendall(
|
|
57
|
+
json.dumps({"status": "error", "message": str(e)}).encode() + b"\n"
|
|
58
|
+
)
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
paths = ipc.runtime_paths()
|
|
64
|
+
|
|
65
|
+
lock = open(paths.lock, "w")
|
|
66
|
+
try:
|
|
67
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
68
|
+
except OSError:
|
|
69
|
+
return # another daemon is already serving
|
|
70
|
+
lock.write(str(os.getpid()))
|
|
71
|
+
lock.flush()
|
|
72
|
+
|
|
73
|
+
print(f"[daemon] starting, pid={os.getpid()}", flush=True)
|
|
74
|
+
engine = Engine()
|
|
75
|
+
engine.prewarm()
|
|
76
|
+
print("[daemon] models loaded and prewarmed", flush=True)
|
|
77
|
+
|
|
78
|
+
paths.sock.unlink(missing_ok=True)
|
|
79
|
+
srv = socket.socket(socket.AF_UNIX)
|
|
80
|
+
srv.bind(str(paths.sock))
|
|
81
|
+
os.chmod(paths.sock, 0o600)
|
|
82
|
+
srv.listen(8)
|
|
83
|
+
srv.settimeout(_idle_timeout())
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
conn, _ = srv.accept()
|
|
89
|
+
except socket.timeout:
|
|
90
|
+
print("[daemon] idle timeout, exiting", flush=True)
|
|
91
|
+
break
|
|
92
|
+
with conn:
|
|
93
|
+
conn.settimeout(ipc.REQUEST_TIMEOUT)
|
|
94
|
+
if not handle_request(conn, engine):
|
|
95
|
+
print("[daemon] shutdown requested", flush=True)
|
|
96
|
+
break
|
|
97
|
+
finally:
|
|
98
|
+
paths.sock.unlink(missing_ok=True)
|
|
99
|
+
paths.lock.unlink(missing_ok=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Lazy wrapper around the Supertonic 3 SDK producing WAV bytes.
|
|
2
|
+
|
|
3
|
+
`supertonic` (and its onnxruntime import) is only loaded when an Engine is
|
|
4
|
+
constructed, so fast CLI paths (--version, --list-voices, --stop) never pay
|
|
5
|
+
the import cost.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SAMPLE_RATE = 44100
|
|
15
|
+
|
|
16
|
+
MODEL_CACHE = Path(os.environ.get("SUPERTONIC_CACHE", "")) if os.environ.get(
|
|
17
|
+
"SUPERTONIC_CACHE"
|
|
18
|
+
) else Path.home() / ".cache" / "supertonic3"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def models_cached() -> bool:
|
|
22
|
+
"""True if the Supertonic model assets look downloaded already."""
|
|
23
|
+
try:
|
|
24
|
+
return any(MODEL_CACHE.rglob("*.onnx"))
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Engine:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
from supertonic import TTS
|
|
32
|
+
|
|
33
|
+
self._tts = TTS(auto_download=True)
|
|
34
|
+
self._styles: dict[str, object] = {}
|
|
35
|
+
|
|
36
|
+
def _style(self, style_id: str):
|
|
37
|
+
if style_id not in self._styles:
|
|
38
|
+
self._styles[style_id] = self._tts.get_voice_style(voice_name=style_id)
|
|
39
|
+
return self._styles[style_id]
|
|
40
|
+
|
|
41
|
+
def synthesize(
|
|
42
|
+
self,
|
|
43
|
+
text: str,
|
|
44
|
+
voice: str = "F1",
|
|
45
|
+
speed: float = 1.05,
|
|
46
|
+
lang: str = "en",
|
|
47
|
+
steps: int = 8,
|
|
48
|
+
) -> tuple[bytes, float]:
|
|
49
|
+
"""Synthesize text and return (wav_bytes, duration_seconds)."""
|
|
50
|
+
import soundfile as sf
|
|
51
|
+
|
|
52
|
+
wav, duration = self._tts.synthesize(
|
|
53
|
+
text=text,
|
|
54
|
+
voice_style=self._style(voice),
|
|
55
|
+
total_steps=steps,
|
|
56
|
+
speed=speed,
|
|
57
|
+
max_chunk_length=300,
|
|
58
|
+
silence_duration=0.3,
|
|
59
|
+
lang=lang,
|
|
60
|
+
verbose=False,
|
|
61
|
+
)
|
|
62
|
+
import numpy as np
|
|
63
|
+
|
|
64
|
+
buf = io.BytesIO()
|
|
65
|
+
sf.write(buf, wav.squeeze(), SAMPLE_RATE, format="WAV", subtype="PCM_16")
|
|
66
|
+
return buf.getvalue(), float(np.asarray(duration).sum())
|
|
67
|
+
|
|
68
|
+
def prewarm(self) -> None:
|
|
69
|
+
"""Run a tiny synthesis so the first real request hits warm ONNX kernels."""
|
|
70
|
+
self.synthesize("Hi.", steps=2)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Client/daemon plumbing: runtime paths, JSON-line framing, spawn + connect.
|
|
2
|
+
|
|
3
|
+
Protocol: one request per connection. Client sends a single JSON line;
|
|
4
|
+
daemon replies a JSON header line, then (for "speak") exactly `nbytes` of
|
|
5
|
+
raw WAV. The socket filename embeds the package version, so upgrading the
|
|
6
|
+
package naturally retires old daemons (they exit via idle timeout).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
|
|
22
|
+
CONNECT_TIMEOUT = 30.0 # daemon cold-start budget (models already cached)
|
|
23
|
+
REQUEST_TIMEOUT = 300.0 # long texts take a while to synthesize
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RuntimePaths:
|
|
28
|
+
dir: Path
|
|
29
|
+
sock: Path
|
|
30
|
+
lock: Path
|
|
31
|
+
daemon_log: Path
|
|
32
|
+
client_log: Path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def runtime_paths() -> RuntimePaths:
|
|
36
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
37
|
+
if runtime and os.path.isdir(runtime):
|
|
38
|
+
base = Path(runtime) / "speak-cli"
|
|
39
|
+
else:
|
|
40
|
+
base = Path.home() / ".cache" / "speak-cli" / "run"
|
|
41
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
os.chmod(base, 0o700)
|
|
43
|
+
log_dir = Path.home() / ".cache" / "speak-cli"
|
|
44
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
return RuntimePaths(
|
|
46
|
+
dir=base,
|
|
47
|
+
sock=base / f"daemon-{__version__}.sock",
|
|
48
|
+
lock=base / f"daemon-{__version__}.lock",
|
|
49
|
+
daemon_log=log_dir / "daemon.log",
|
|
50
|
+
client_log=log_dir / "client.log",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def log_client(message: str) -> None:
|
|
55
|
+
try:
|
|
56
|
+
with open(runtime_paths().client_log, "a") as f:
|
|
57
|
+
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def recv_line(sock: socket.socket, max_bytes: int = 1_000_000) -> bytes:
|
|
63
|
+
chunks = []
|
|
64
|
+
total = 0
|
|
65
|
+
while total < max_bytes:
|
|
66
|
+
b = sock.recv(1)
|
|
67
|
+
if not b:
|
|
68
|
+
break
|
|
69
|
+
if b == b"\n":
|
|
70
|
+
return b"".join(chunks)
|
|
71
|
+
chunks.append(b)
|
|
72
|
+
total += 1
|
|
73
|
+
raise ValueError("malformed message (no newline)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
77
|
+
buf = bytearray()
|
|
78
|
+
while len(buf) < n:
|
|
79
|
+
chunk = sock.recv(min(65536, n - len(buf)))
|
|
80
|
+
if not chunk:
|
|
81
|
+
raise ConnectionError("connection closed mid-payload")
|
|
82
|
+
buf.extend(chunk)
|
|
83
|
+
return bytes(buf)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _request(sock_path: Path, req: dict) -> bytes:
|
|
87
|
+
"""Send one request; return WAV bytes (or b"" for control commands)."""
|
|
88
|
+
with socket.socket(socket.AF_UNIX) as s:
|
|
89
|
+
s.settimeout(REQUEST_TIMEOUT)
|
|
90
|
+
s.connect(str(sock_path))
|
|
91
|
+
s.sendall(json.dumps(req).encode() + b"\n")
|
|
92
|
+
header = json.loads(recv_line(s))
|
|
93
|
+
if header.get("status") != "ok":
|
|
94
|
+
raise RuntimeError(header.get("message", "daemon error"))
|
|
95
|
+
nbytes = header.get("nbytes", 0)
|
|
96
|
+
return recv_exact(s, nbytes) if nbytes else b""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def spawn_daemon(paths: RuntimePaths) -> None:
|
|
100
|
+
"""Start a detached daemon process; harmless if one is already running."""
|
|
101
|
+
with open(paths.daemon_log, "ab") as log:
|
|
102
|
+
subprocess.Popen(
|
|
103
|
+
[sys.executable, "-m", "speak_cli.daemon"],
|
|
104
|
+
stdin=subprocess.DEVNULL,
|
|
105
|
+
stdout=log,
|
|
106
|
+
stderr=log,
|
|
107
|
+
start_new_session=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_wav_from_daemon(req: dict) -> bytes:
|
|
112
|
+
"""Request synthesis from the daemon, auto-spawning it if needed.
|
|
113
|
+
|
|
114
|
+
Raises on any failure — the caller falls back to one-shot synthesis.
|
|
115
|
+
"""
|
|
116
|
+
if os.name != "posix":
|
|
117
|
+
raise RuntimeError("daemon mode is POSIX-only")
|
|
118
|
+
paths = runtime_paths()
|
|
119
|
+
try:
|
|
120
|
+
return _request(paths.sock, req)
|
|
121
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
122
|
+
# Not running (or crashed leaving a stale socket): clean up and spawn.
|
|
123
|
+
paths.sock.unlink(missing_ok=True)
|
|
124
|
+
spawn_daemon(paths)
|
|
125
|
+
deadline = time.monotonic() + CONNECT_TIMEOUT
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
return _request(paths.sock, req)
|
|
129
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
130
|
+
if time.monotonic() > deadline:
|
|
131
|
+
raise TimeoutError(
|
|
132
|
+
f"daemon did not start within {CONNECT_TIMEOUT:.0f}s"
|
|
133
|
+
) from None
|
|
134
|
+
time.sleep(0.15)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def stop_daemons() -> int:
|
|
138
|
+
"""Shut down daemons of any version in any runtime dir; returns how many responded."""
|
|
139
|
+
candidates = {runtime_paths().dir, Path.home() / ".cache" / "speak-cli" / "run"}
|
|
140
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
141
|
+
if runtime:
|
|
142
|
+
candidates.add(Path(runtime) / "speak-cli")
|
|
143
|
+
stopped = 0
|
|
144
|
+
for base in candidates:
|
|
145
|
+
if not base.is_dir():
|
|
146
|
+
continue
|
|
147
|
+
for sock_path in base.glob("daemon-*.sock"):
|
|
148
|
+
try:
|
|
149
|
+
_request(sock_path, {"cmd": "shutdown"})
|
|
150
|
+
stopped += 1
|
|
151
|
+
except OSError:
|
|
152
|
+
sock_path.unlink(missing_ok=True) # stale
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return stopped
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Unicode-script-based language guessing for Supertonic 3.
|
|
2
|
+
|
|
3
|
+
Deliberately tiny: no models, no dependencies. Latin-script languages are
|
|
4
|
+
indistinguishable this way and map to English — use --lang to override.
|
|
5
|
+
Scripts Supertonic doesn't support (Han-only/Chinese, Thai, Hebrew, ...)
|
|
6
|
+
map to "na", Supertonic's language-agnostic mode.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# (start, end, bucket) — checked in order, first match wins
|
|
12
|
+
_RANGES: list[tuple[int, int, str]] = [
|
|
13
|
+
(0x0600, 0x06FF, "ar"),
|
|
14
|
+
(0x0750, 0x077F, "ar"),
|
|
15
|
+
(0x08A0, 0x08FF, "ar"),
|
|
16
|
+
(0xFB50, 0xFDFF, "ar"),
|
|
17
|
+
(0xFE70, 0xFEFF, "ar"),
|
|
18
|
+
(0x3040, 0x30FF, "kana"),
|
|
19
|
+
(0x31F0, 0x31FF, "kana"),
|
|
20
|
+
(0xAC00, 0xD7AF, "ko"),
|
|
21
|
+
(0x1100, 0x11FF, "ko"),
|
|
22
|
+
(0x3130, 0x318F, "ko"),
|
|
23
|
+
(0x4E00, 0x9FFF, "han"),
|
|
24
|
+
(0x3400, 0x4DBF, "han"),
|
|
25
|
+
(0xF900, 0xFAFF, "han"),
|
|
26
|
+
(0x0400, 0x04FF, "cyr"),
|
|
27
|
+
(0x0500, 0x052F, "cyr"),
|
|
28
|
+
(0x0370, 0x03FF, "el"),
|
|
29
|
+
(0x1F00, 0x1FFF, "el"),
|
|
30
|
+
(0x0900, 0x097F, "hi"),
|
|
31
|
+
(0x0E00, 0x0E7F, "unsupported"), # Thai
|
|
32
|
+
(0x0590, 0x05FF, "unsupported"), # Hebrew
|
|
33
|
+
(0x0041, 0x005A, "latin"),
|
|
34
|
+
(0x0061, 0x007A, "latin"),
|
|
35
|
+
(0x00C0, 0x024F, "latin"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_BUCKET_TO_LANG = {
|
|
39
|
+
"ar": "ar",
|
|
40
|
+
"ko": "ko",
|
|
41
|
+
"cyr": "ru",
|
|
42
|
+
"el": "el",
|
|
43
|
+
"hi": "hi",
|
|
44
|
+
"han": "na",
|
|
45
|
+
"unsupported": "na",
|
|
46
|
+
"latin": "en",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
SUPPORTED_LANGS = frozenset(
|
|
50
|
+
"en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv "
|
|
51
|
+
"nl pl pt ro ru sk sl sv tr uk vi na".split()
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def detect_lang(text: str) -> str:
|
|
56
|
+
counts: dict[str, int] = {}
|
|
57
|
+
for ch in text:
|
|
58
|
+
cp = ord(ch)
|
|
59
|
+
for lo, hi, bucket in _RANGES:
|
|
60
|
+
if lo <= cp <= hi:
|
|
61
|
+
counts[bucket] = counts.get(bucket, 0) + 1
|
|
62
|
+
break
|
|
63
|
+
if not counts:
|
|
64
|
+
return "en"
|
|
65
|
+
# Japanese text is often Han-heavy; any kana at all means Japanese.
|
|
66
|
+
if counts.get("kana"):
|
|
67
|
+
return "ja"
|
|
68
|
+
dominant = max(counts, key=lambda k: counts[k])
|
|
69
|
+
return _BUCKET_TO_LANG[dominant]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Play WAV bytes through whatever audio player the system has.
|
|
2
|
+
|
|
3
|
+
No Python audio dependency on purpose: simpleaudio is unmaintained and
|
|
4
|
+
sounddevice needs a system PortAudio library. Every mainstream desktop has
|
|
5
|
+
at least one of the players below; each is tried until one exits 0.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlaybackError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_PLAYERS: list[tuple[str, list[str]]] = [
|
|
21
|
+
("paplay", []),
|
|
22
|
+
("pw-play", []),
|
|
23
|
+
("aplay", ["-q"]),
|
|
24
|
+
("afplay", []),
|
|
25
|
+
("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"]),
|
|
26
|
+
("play", ["-q"]),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tmp_dir() -> str | None:
|
|
31
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
32
|
+
if runtime and os.path.isdir(runtime) and os.access(runtime, os.W_OK):
|
|
33
|
+
return runtime # tmpfs: no disk I/O
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def play(wav_bytes: bytes, verbose: bool = False) -> None:
|
|
38
|
+
"""Play WAV bytes; raises PlaybackError if no player works."""
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", dir=_tmp_dir()) as f:
|
|
41
|
+
f.write(wav_bytes)
|
|
42
|
+
f.flush()
|
|
43
|
+
for name, args in _PLAYERS:
|
|
44
|
+
exe = shutil.which(name)
|
|
45
|
+
if not exe:
|
|
46
|
+
continue
|
|
47
|
+
proc = subprocess.run(
|
|
48
|
+
[exe, *args, f.name],
|
|
49
|
+
stdout=subprocess.DEVNULL,
|
|
50
|
+
stderr=subprocess.PIPE,
|
|
51
|
+
)
|
|
52
|
+
if proc.returncode == 0:
|
|
53
|
+
if verbose:
|
|
54
|
+
print(f"[speak] played via {name}", flush=True)
|
|
55
|
+
return
|
|
56
|
+
errors.append(f"{name}: exit {proc.returncode}")
|
|
57
|
+
detail = "; ".join(errors) if errors else "no audio player found on PATH"
|
|
58
|
+
raise PlaybackError(
|
|
59
|
+
f"could not play audio ({detail}). "
|
|
60
|
+
"Use --out FILE to save the audio to a WAV file instead."
|
|
61
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Friendly-name registry for Supertonic 3 voice styles.
|
|
2
|
+
|
|
3
|
+
Upstream ships anonymous style files (M1-M5, F1-F5); we give each a name.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# name -> (style_id, gender)
|
|
11
|
+
VOICES: dict[str, tuple[str, str]] = {
|
|
12
|
+
"sara": ("F1", "female"),
|
|
13
|
+
"emma": ("F2", "female"),
|
|
14
|
+
"lily": ("F3", "female"),
|
|
15
|
+
"maya": ("F4", "female"),
|
|
16
|
+
"nora": ("F5", "female"),
|
|
17
|
+
"james": ("M1", "male"),
|
|
18
|
+
"daniel": ("M2", "male"),
|
|
19
|
+
"leo": ("M3", "male"),
|
|
20
|
+
"ryan": ("M4", "male"),
|
|
21
|
+
"noah": ("M5", "male"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
DEFAULT_VOICE = "sara"
|
|
25
|
+
|
|
26
|
+
def default_voice() -> str:
|
|
27
|
+
env = os.environ.get("SPEAK_VOICE", "").strip()
|
|
28
|
+
if env:
|
|
29
|
+
try:
|
|
30
|
+
return resolve(env)[0]
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass # bad SPEAK_VOICE should not break the tool
|
|
33
|
+
return DEFAULT_VOICE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve(voice: str) -> tuple[str, str]:
|
|
37
|
+
"""Resolve a voice name (case-insensitive) to (name, style_id).
|
|
38
|
+
|
|
39
|
+
Raises ValueError with a helpful message for unknown voices.
|
|
40
|
+
"""
|
|
41
|
+
key = voice.strip().lower()
|
|
42
|
+
if key in VOICES:
|
|
43
|
+
return key, VOICES[key][0]
|
|
44
|
+
valid = ", ".join(VOICES)
|
|
45
|
+
raise ValueError(f"unknown voice {voice!r}. Valid voices: {valid}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def listing() -> str:
|
|
49
|
+
"""Human-readable voice table for --list-voices."""
|
|
50
|
+
default = default_voice()
|
|
51
|
+
lines = []
|
|
52
|
+
for name, (_, gender) in VOICES.items():
|
|
53
|
+
mark = " (default)" if name == default else ""
|
|
54
|
+
lines.append(f"{name:<7} {gender}{mark}")
|
|
55
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from speak_cli import __version__, ipc
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_runtime_paths_created(tmp_path, monkeypatch):
|
|
5
|
+
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
|
|
6
|
+
paths = ipc.runtime_paths()
|
|
7
|
+
assert paths.dir.is_dir()
|
|
8
|
+
assert (paths.dir.stat().st_mode & 0o777) == 0o700
|
|
9
|
+
assert paths.sock.name == f"daemon-{__version__}.sock"
|
|
10
|
+
assert paths.sock.parent == paths.dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_runtime_paths_fallback_without_xdg(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
|
|
15
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
16
|
+
paths = ipc.runtime_paths()
|
|
17
|
+
assert str(paths.dir).startswith(str(tmp_path))
|
|
18
|
+
assert paths.dir.is_dir()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from speak_cli.langdetect import SUPPORTED_LANGS, detect_lang
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_english():
|
|
5
|
+
assert detect_lang("Hello, world!") == "en"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_arabic():
|
|
9
|
+
assert detect_lang("مرحبا بالعالم") == "ar"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_arabic_with_latin_word():
|
|
13
|
+
assert detect_lang("مرحبا بالعالم من Python") == "ar"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_japanese_kana():
|
|
17
|
+
assert detect_lang("こんにちは") == "ja"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_japanese_han_heavy_with_kana():
|
|
21
|
+
assert detect_lang("日本語の文章です") == "ja"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_han_only_maps_to_na():
|
|
25
|
+
assert detect_lang("你好世界") == "na"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_korean():
|
|
29
|
+
assert detect_lang("안녕하세요") == "ko"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cyrillic():
|
|
33
|
+
assert detect_lang("Привет мир") == "ru"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_greek():
|
|
37
|
+
assert detect_lang("Γεια σου κόσμε") == "el"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_hindi():
|
|
41
|
+
assert detect_lang("नमस्ते दुनिया") == "hi"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hebrew_unsupported_maps_to_na():
|
|
45
|
+
assert detect_lang("שלום עולם") == "na"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_numbers_only_default_en():
|
|
49
|
+
assert detect_lang("12345 !!!") == "en"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_empty():
|
|
53
|
+
assert detect_lang("") == "en"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_all_outputs_supported():
|
|
57
|
+
for text in ["hi", "مرحبا", "こんにちは", "你好", "안녕", "Привет", "Γεια", "नमस्ते"]:
|
|
58
|
+
assert detect_lang(text) in SUPPORTED_LANGS
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from speak_cli import voices
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_resolve_friendly_name():
|
|
7
|
+
assert voices.resolve("sara") == ("sara", "F1")
|
|
8
|
+
assert voices.resolve("noah") == ("noah", "M5")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_resolve_case_insensitive():
|
|
12
|
+
assert voices.resolve("SARA") == ("sara", "F1")
|
|
13
|
+
assert voices.resolve("James") == ("james", "M1")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_resolve_raw_id_rejected():
|
|
17
|
+
with pytest.raises(ValueError, match="unknown voice"):
|
|
18
|
+
voices.resolve("F1")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_resolve_unknown_raises():
|
|
22
|
+
with pytest.raises(ValueError, match="unknown voice"):
|
|
23
|
+
voices.resolve("bogus")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_default_voice_env_override(monkeypatch):
|
|
27
|
+
monkeypatch.setenv("SPEAK_VOICE", "noah")
|
|
28
|
+
assert voices.default_voice() == "noah"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_default_voice_bad_env_falls_back(monkeypatch):
|
|
32
|
+
monkeypatch.setenv("SPEAK_VOICE", "not-a-voice")
|
|
33
|
+
assert voices.default_voice() == voices.DEFAULT_VOICE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_listing_contains_all_and_default(monkeypatch):
|
|
37
|
+
monkeypatch.delenv("SPEAK_VOICE", raising=False)
|
|
38
|
+
out = voices.listing()
|
|
39
|
+
for name in voices.VOICES:
|
|
40
|
+
assert name in out
|
|
41
|
+
assert "(default)" in out
|